mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
fix(web): adapt request config types for axios 1.20
This commit is contained in:
335 files changed
+20839
-5260
No files matched your search
@@ -9,7 +9,7 @@ Mandatory conventions for quota, pricing, pre-consume, settlement, refund, and u
|
||||
**Billing safety invariants:** Quota/billing code MUST never produce a negative charge (a credit) from arithmetic overflow or unvalidated input. Apply defense in depth:
|
||||
|
||||
- Every user-controlled quantity that becomes a billing multiplier (image `n`, video `seconds`/`duration`, resolution/quality ratios, batch counts) MUST be bounded before it reaches quota calculation. Reject out-of-range values at request validation with a 400. Existing bounds: `dto.MaxImageN` for image generation count, `relaycommon.MaxTaskDurationSeconds` for task video duration, `maxTokensLimit` (`relay/helper/valid_request.go`) for `max_tokens`-family fields on every relay format (OpenAI, Claude, Gemini, Responses). Reuse these constants instead of introducing new ad hoc limits for the same concepts. When adding a new relay format or request DTO, bound its max-tokens and count fields in its validator from day one.
|
||||
- Watch for validation bypass paths: passthrough fields (e.g. `Extra["parameters"]`), task `metadata` maps, and multipart form fields can carry the same quantities around the standard DTO validation. Any adaptor that reads a multiplier from such a path must enforce the same bound (or clamp) locally. For task plugins the host does this: it rejects out-of-range duration and count members at any depth of the decoded request. The one exception is a duration of -1 (the vendor picks the length) for plugins that declare `duration-auto@1`; they must reserve the model's longest duration and settle on the usage the vendor reports.
|
||||
- Watch for validation bypass paths: passthrough fields (e.g. `Extra["parameters"]`), task `metadata` maps, and multipart form fields can carry the same quantities around the standard DTO validation. Any adaptor that reads a multiplier from such a path must enforce the same bound (or clamp) locally. Task plugins validate their own requests: the host bounds every usage fact a plugin returns before quota calculation and does not interpret request members. A plugin MUST derive its facts from the request it sends upstream, including values merged from `metadata` or other passthrough maps, and MUST bound any request quantity it turns into a token or credit fact; the host caps `second` and `count` facts but only saturates `token` and `credit` facts at the int32 quota boundary. A plugin that forwards a duration of -1 (the vendor picks the length) must reserve the model's longest duration and settle on the usage the vendor reports.
|
||||
- Durations parsed from media metadata are user/upstream-controlled too: audio file headers (transcription token counting, TTS response duration) and upstream deduction numbers (e.g. Kling `FinalUnitDeduction`) can claim absurd values. Convert them with saturation before they become token counts.
|
||||
- Never convert a computed quota or token count to `int` with a bare cast like `int(float64(quota) * ratio)`, `int(math.Round(...))` on unbounded input, or `int(decimal.IntPart())`. All quota rounding/conversion is centralized in `common/quota_math.go`; use those helpers: `common.QuotaFromFloat` (truncating) for float products, `common.QuotaRound` (half-away-from-zero) where rounding is intended, and `common.QuotaFromDecimal` for decimal products. `billingexpr.QuotaRound` delegates to `common.QuotaRound`. Do not reintroduce local conversion helpers or bare casts. Single-request saturation stays at the int32 boundary so batch accumulation cannot approach 64-bit wraparound; wallet/top-up conversion uses `common.WalletQuotaFromDecimalStrict` with the JavaScript-safe `common.MaxWalletQuota` boundary. Every clamp/NaN fallback is logged via `common.SysError`.
|
||||
- Saturation events are also audited: each helper has a `*Checked` variant (`common.QuotaFromFloatChecked` / `QuotaRoundChecked` / `QuotaFromDecimalChecked`) that additionally returns a `*common.QuotaClamp` when clamping occurred. Billing paths that compute a charge capture that clamp onto `relayInfo.QuotaClamp` (or thread it into task settlement) and, right before writing the consume/task log, call `attachQuotaSaturation` (in `service/log_info_generate.go`) which nests the marker under the log's `other.admin_info.quota_saturation` and emits a request-correlated `logger.LogWarn`. Nesting under `admin_info` makes it admin-only for free (non-admin log views strip `admin_info`). When adding a new billing path, use the `*Checked` variant and surface the clamp the same way so the anomaly stays auditable in both the admin log UI and backend logs.
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@
|
||||
# 更新任务启用
|
||||
# UPDATE_TASK=true
|
||||
# 异步任务硬超时(分钟),按提交时间计算,超时未完成的任务标记失败并退款;0 表示禁用
|
||||
# TASK_TIMEOUT_MINUTES=1440
|
||||
# TASK_TIMEOUT_MINUTES=4320
|
||||
# 异步任务连续轮询失败阈值(上游 429/5xx/401/403、网络错误、无法识别的响应),达到后任务标记失败并退款;正常轮询成功一次即归零
|
||||
# TASK_POLL_MAX_FAILURES=20
|
||||
|
||||
|
||||
@@ -87,13 +87,13 @@ jobs:
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: '1.4.0'
|
||||
bun-version: '1.4.4'
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
run: bun check -b
|
||||
|
||||
- name: Test
|
||||
run: bun run test
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: '1.4.0'
|
||||
bun-version: '1.4.4'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: '>=1.26'
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build frontend
|
||||
env:
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: '1.4.0'
|
||||
bun-version: '1.4.4'
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: '>=1.26'
|
||||
go-version-file: go.mod
|
||||
- name: Build Backend (amd64)
|
||||
run: |
|
||||
go mod download
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: '1.4.0'
|
||||
bun-version: '1.4.4'
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: '>=1.26'
|
||||
go-version-file: go.mod
|
||||
- name: Build Backend
|
||||
run: |
|
||||
go mod download
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: '1.4.0'
|
||||
bun-version: '1.4.4'
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version: '>=1.26'
|
||||
go-version-file: go.mod
|
||||
- name: Build Backend
|
||||
run: |
|
||||
go mod download
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1.4.0@sha256:5ff609364c049b54eb0ff560ec96319729a972078ef2c755d758f0c6ef89c2d6 AS builder
|
||||
FROM oven/bun:1.4.4@sha256:1d22548f177f4ff38e0c15baa3c2c46c6c2060ba2d1bc573bb4720b407548f1d AS builder
|
||||
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
@@ -7,7 +7,7 @@ COPY ./web ./
|
||||
COPY ./VERSION /build/VERSION
|
||||
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
|
||||
|
||||
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
|
||||
FROM golang:1.26.10-alpine@sha256:9ce231b28998b32e116927c61aa5c406183061d4f3f2d61b292c88e7f6bdf7ca AS builder2
|
||||
ENV GO111MODULE=on CGO_ENABLED=0 GOWORK=off
|
||||
|
||||
ARG TARGETOS
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Backend-only build for frontend development
|
||||
# Skips frontend build and uses a placeholder for //go:embed web/dist
|
||||
|
||||
FROM golang:1.26.1-alpine AS builder
|
||||
FROM golang:1.26.10-alpine AS builder
|
||||
|
||||
ENV GO111MODULE=on CGO_ENABLED=0
|
||||
ARG TARGETOS
|
||||
|
||||
+1
-1
@@ -286,7 +286,7 @@ Open [http://localhost:5173](http://localhost:5173); the development server prox
|
||||
| `web/` | Web console; see [frontend conventions](./web/AGENTS.md) |
|
||||
| [electron/](./electron/README.md) | Desktop wrapper and packaging |
|
||||
|
||||
Read [AGENTS.md](./AGENTS.md) before contributing. Run checks appropriate to your change, including `make test` for the Go modules and `bun run typecheck`, `bun run lint`, `bun run test`, and `bun run build` in `web/` for frontend changes. Changes to RelayKit must also pass `GOWORK=off go build ./...` from `relaykit/`.
|
||||
Read [AGENTS.md](./AGENTS.md) before contributing. Run checks appropriate to your change, including `make test` for the Go modules and `bun check -b`, `bun run lint`, `bun run test`, and `bun run build` in `web/` for frontend changes. Changes to RelayKit must also pass `GOWORK=off go build ./...` from `relaykit/`.
|
||||
|
||||
<a id="documentation"></a>
|
||||
|
||||
|
||||
+1
-1
@@ -267,7 +267,7 @@ Ouvrez [http://localhost:5173](http://localhost:5173) ; les requêtes API sont r
|
||||
| `web/` | Console web ; voir les [conventions frontend](./web/AGENTS.md) |
|
||||
| [electron/](./electron/README.md) | Application de bureau et packaging |
|
||||
|
||||
Lisez [AGENTS.md](./AGENTS.md) avant de contribuer. Exécutez les vérifications adaptées : `make test` pour les modules Go ; `bun run typecheck`, `bun run lint`, `bun run test` et `bun run build` dans `web/` pour le frontend. Une modification de RelayKit nécessite aussi `GOWORK=off go build ./...` depuis `relaykit/`.
|
||||
Lisez [AGENTS.md](./AGENTS.md) avant de contribuer. Exécutez les vérifications adaptées : `make test` pour les modules Go ; `bun check -b`, `bun run lint`, `bun run test` et `bun run build` dans `web/` pour le frontend. Une modification de RelayKit nécessite aussi `GOWORK=off go build ./...` depuis `relaykit/`.
|
||||
|
||||
<a id="documentation"></a>
|
||||
|
||||
|
||||
+1
-1
@@ -267,7 +267,7 @@ bun run dev -- --port 5173
|
||||
| `web/` | Web 管理画面。[フロントエンドの規約](./web/AGENTS.md)を参照 |
|
||||
| [electron/](./electron/README.md) | デスクトップアプリのラッパーとパッケージング |
|
||||
|
||||
貢献前に [AGENTS.md](./AGENTS.md) を確認してください。変更に応じて、Go モジュールは `make test`、フロントエンドは `web/` で `bun run typecheck`、`bun run lint`、`bun run test`、`bun run build` を実行します。RelayKit を変更した場合は、`relaykit/` で `GOWORK=off go build ./...` も実行してください。
|
||||
貢献前に [AGENTS.md](./AGENTS.md) を確認してください。変更に応じて、Go モジュールは `make test`、フロントエンドは `web/` で `bun check -b`、`bun run lint`、`bun run test`、`bun run build` を実行します。RelayKit を変更した場合は、`relaykit/` で `GOWORK=off go build ./...` も実行してください。
|
||||
|
||||
<a id="documentation"></a>
|
||||
|
||||
|
||||
@@ -282,7 +282,7 @@ Open [http://localhost:5173](http://localhost:5173); the development server prox
|
||||
| `web/` | Web console; see [frontend conventions](./web/AGENTS.md) |
|
||||
| [electron/](./electron/README.md) | Desktop wrapper and packaging |
|
||||
|
||||
Read [AGENTS.md](./AGENTS.md) before contributing. Run checks appropriate to your change, including `make test` for the Go modules and `bun run typecheck`, `bun run lint`, `bun run test`, and `bun run build` in `web/` for frontend changes. Changes to RelayKit must also pass `GOWORK=off go build ./...` from `relaykit/`.
|
||||
Read [AGENTS.md](./AGENTS.md) before contributing. Run checks appropriate to your change, including `make test` for the Go modules and `bun check -b`, `bun run lint`, `bun run test`, and `bun run build` in `web/` for frontend changes. Changes to RelayKit must also pass `GOWORK=off go build ./...` from `relaykit/`.
|
||||
|
||||
<a id="documentation"></a>
|
||||
|
||||
|
||||
+1
-1
@@ -282,7 +282,7 @@ bun run dev -- --port 5173
|
||||
| `web/` | Web 控制台,参阅[前端开发约定](./web/AGENTS.md) |
|
||||
| [electron/](./electron/README.md) | 桌面封装与打包 |
|
||||
|
||||
贡献前请阅读 [AGENTS.md](./AGENTS.md)。按改动范围执行检查:Go 模块使用 `make test`;前端在 `web/` 下运行 `bun run typecheck`、`bun run lint`、`bun run test` 和 `bun run build`。修改 RelayKit 后还必须在 `relaykit/` 下执行 `GOWORK=off go build ./...`。
|
||||
贡献前请阅读 [AGENTS.md](./AGENTS.md)。按改动范围执行检查:Go 模块使用 `make test`;前端在 `web/` 下运行 `bun check -b`、`bun run lint`、`bun run test` 和 `bun run build`。修改 RelayKit 后还必须在 `relaykit/` 下执行 `GOWORK=off go build ./...`。
|
||||
|
||||
<a id="documentation"></a>
|
||||
|
||||
|
||||
+1
-1
@@ -266,7 +266,7 @@ bun run dev -- --port 5173
|
||||
| `web/` | Web 控制台,參閱[前端開發約定](./web/AGENTS.md) |
|
||||
| [electron/](./electron/README.md) | 桌面封裝與打包 |
|
||||
|
||||
貢獻前請閱讀 [AGENTS.md](./AGENTS.md)。依改動範圍執行檢查:Go 模組使用 `make test`;前端在 `web/` 下執行 `bun run typecheck`、`bun run lint`、`bun run test` 和 `bun run build`。修改 RelayKit 後還必須在 `relaykit/` 下執行 `GOWORK=off go build ./...`。
|
||||
貢獻前請閱讀 [AGENTS.md](./AGENTS.md)。依改動範圍執行檢查:Go 模組使用 `make test`;前端在 `web/` 下執行 `bun check -b`、`bun run lint`、`bun run test` 和 `bun run build`。修改 RelayKit 後還必須在 `relaykit/` 下執行 `GOWORK=off go build ./...`。
|
||||
|
||||
<a id="documentation"></a>
|
||||
|
||||
|
||||
@@ -156,7 +156,6 @@ Transitive dependencies should be audited before a final external release.
|
||||
| web | development | npm | `@types/node-forge` | `1.3.14` | MIT |
|
||||
| web | development | npm | `@types/react` | `19.2.17` | MIT |
|
||||
| web | development | npm | `@types/react-dom` | `19.2.3` | MIT |
|
||||
| web | development | npm | `@typescript/native-preview` | `7.0.0-dev.20260707.2` | Apache-2.0 |
|
||||
| web | development | npm | `@xyflow/react` | `12.11.2` | MIT |
|
||||
| web | development | npm | `embla-carousel-react` | `8.6.0` | MIT |
|
||||
| web | development | npm | `jsdom` | `29.1.1` | MIT |
|
||||
|
||||
@@ -133,6 +133,7 @@ var QuotaForInvitee = 0
|
||||
var ChannelDisableThreshold = 5.0
|
||||
var AutomaticDisableChannelEnabled = false
|
||||
var AutomaticEnableChannelEnabled = false
|
||||
var QuotaRemindEnabled = true
|
||||
var QuotaRemindThreshold = 1000
|
||||
|
||||
// PreConsumedQuota is retained for old option clients; token reservations now
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 容器自身资源采样。
|
||||
//
|
||||
// 历史实现直接读取宿主机口径(/proc/stat 与 /proc/meminfo),把同节点其它
|
||||
// 工作负载的占用也算成本进程过载,导致正常的 relay 流量被 503 熔断。这里
|
||||
// 改为优先读取当前容器的 cgroup 配额与用量,只有在拿不到 cgroup 数据时才由
|
||||
// 调用方回退到宿主机口径。
|
||||
|
||||
const (
|
||||
defaultCgroupRoot = "/sys/fs/cgroup"
|
||||
// cgroup v1 用接近 int64 上限的值表示"未限制"。
|
||||
unlimitedMemoryBytes = uint64(1) << 60
|
||||
)
|
||||
|
||||
// containerUsage 记录上一次采样点,用来计算区间内的 CPU 占用。
|
||||
type containerUsage struct {
|
||||
cgroupRoot string
|
||||
// schedulableCPUs 是本进程可调度的逻辑 CPU 数(Linux 上含 cpuset/affinity 限制)。
|
||||
schedulableCPUs float64
|
||||
cpuLastUsec uint64
|
||||
cpuLastSampled time.Time
|
||||
}
|
||||
|
||||
func newContainerUsage() *containerUsage {
|
||||
// runtime.NumCPU 在 Linux 上于进程启动时按 sched_getaffinity 计数,不受 GOMAXPROCS 影响。
|
||||
return &containerUsage{cgroupRoot: defaultCgroupRoot, schedulableCPUs: float64(runtime.NumCPU())}
|
||||
}
|
||||
|
||||
// cpuSaturation 返回相对容器可用 CPU 的使用率(百分比)。
|
||||
// 可用 CPU 取 cgroup 配额与可调度 CPU 的较小者:配额大于 cpuset 或节点核数时,
|
||||
// 按配额计算会让容器打满也无法触发保护。
|
||||
// ok=false 表示当前环境没有可用的 cgroup CPU 数据,调用方应回退到宿主机口径。
|
||||
func (u *containerUsage) cpuSaturation(now time.Time) (float64, bool) {
|
||||
limitCores, ok := u.readCgroupCPUQuotaCores()
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
if u.schedulableCPUs > 0 {
|
||||
limitCores = min(limitCores, u.schedulableCPUs)
|
||||
}
|
||||
usageUsec, ok := u.readCgroupCPUUsageUsec()
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
if u.cpuLastSampled.IsZero() || usageUsec < u.cpuLastUsec || !now.After(u.cpuLastSampled) {
|
||||
u.cpuLastUsec = usageUsec
|
||||
u.cpuLastSampled = now
|
||||
return 0, false
|
||||
}
|
||||
elapsed := now.Sub(u.cpuLastSampled)
|
||||
if elapsed < time.Second {
|
||||
return 0, false
|
||||
}
|
||||
deltaUsec := usageUsec - u.cpuLastUsec
|
||||
u.cpuLastUsec = usageUsec
|
||||
u.cpuLastSampled = now
|
||||
|
||||
usedCores := float64(deltaUsec) / 1e6 / elapsed.Seconds()
|
||||
return usedCores / limitCores * 100, true
|
||||
}
|
||||
|
||||
// memoryUsage 返回容器内存工作集相对上限的使用率(百分比)。
|
||||
// 工作集 = 用量 - 非活跃文件缓存,与 docker stats、kubelet 一致:用量包含可回收的
|
||||
// 页缓存,写日志或文件后会长期贴近上限,不代表内存压力。
|
||||
// ok=false 时调用方回退宿主机口径(同样不含缓存):内存未限制、文件不可读、
|
||||
// 或 memory.stat 缺少非活跃缓存字段。宁可回退旧口径,也不把含缓存的用量当作压力。
|
||||
func (u *containerUsage) memoryUsage() (float64, bool) {
|
||||
usagePath := filepath.Join(u.cgroupRoot, "memory.current")
|
||||
limitPath := filepath.Join(u.cgroupRoot, "memory.max")
|
||||
statPath := filepath.Join(u.cgroupRoot, "memory.stat")
|
||||
inactiveFileKey := "inactive_file"
|
||||
if _, err := os.Stat(usagePath); err != nil {
|
||||
v1Dir := filepath.Join(u.cgroupRoot, "memory")
|
||||
usagePath = filepath.Join(v1Dir, "memory.usage_in_bytes")
|
||||
limitPath = filepath.Join(v1Dir, "memory.limit_in_bytes")
|
||||
statPath = filepath.Join(v1Dir, "memory.stat")
|
||||
inactiveFileKey = "total_inactive_file"
|
||||
}
|
||||
|
||||
// cgroup v2 未限制时 memory.max 为 "max",解析失败即视为不可用。
|
||||
limit, ok := readCgroupUint(limitPath)
|
||||
if !ok || limit == 0 || limit >= unlimitedMemoryBytes {
|
||||
return 0, false
|
||||
}
|
||||
usage, ok := readCgroupUint(usagePath)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
inactiveFile, ok := readCgroupStatValue(statPath, inactiveFileKey)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
// 用量与 memory.stat 不是同一时刻的快照,缓存可能短暂大于用量,按 0 处理防止下溢。
|
||||
workingSet := usage - min(inactiveFile, usage)
|
||||
return float64(workingSet) / float64(limit) * 100, true
|
||||
}
|
||||
|
||||
// readCgroupCPUQuotaCores 返回 cgroup CPU 配额(核)。ok=false 表示未限制或不可读。
|
||||
func (u *containerUsage) readCgroupCPUQuotaCores() (float64, bool) {
|
||||
if raw, err := os.ReadFile(filepath.Join(u.cgroupRoot, "cpu.max")); err == nil {
|
||||
quotaText, periodText, found := strings.Cut(strings.TrimSpace(string(raw)), " ")
|
||||
if !found || quotaText == "max" {
|
||||
return 0, false
|
||||
}
|
||||
quota, err := strconv.ParseUint(quotaText, 10, 64)
|
||||
if err != nil || quota == 0 {
|
||||
return 0, false
|
||||
}
|
||||
period, err := strconv.ParseUint(strings.TrimSpace(periodText), 10, 64)
|
||||
if err != nil || period == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return float64(quota) / float64(period), true
|
||||
}
|
||||
|
||||
// cgroup v1 未限制时 cfs_quota_us 为 -1,解析失败即视为不可用。
|
||||
v1Dir := filepath.Join(u.cgroupRoot, "cpu")
|
||||
quota, ok := readCgroupUint(filepath.Join(v1Dir, "cpu.cfs_quota_us"))
|
||||
if !ok || quota == 0 {
|
||||
return 0, false
|
||||
}
|
||||
period, ok := readCgroupUint(filepath.Join(v1Dir, "cpu.cfs_period_us"))
|
||||
if !ok || period == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return float64(quota) / float64(period), true
|
||||
}
|
||||
|
||||
func (u *containerUsage) readCgroupCPUUsageUsec() (uint64, bool) {
|
||||
statPath := filepath.Join(u.cgroupRoot, "cpu.stat")
|
||||
if _, err := os.Stat(statPath); err == nil {
|
||||
return readCgroupStatValue(statPath, "usage_usec")
|
||||
}
|
||||
// cgroup v1 的 cpuacct.usage 单位是纳秒。
|
||||
nanos, ok := readCgroupUint(filepath.Join(u.cgroupRoot, "cpuacct", "cpuacct.usage"))
|
||||
return nanos / 1000, ok
|
||||
}
|
||||
|
||||
// readCgroupUint 读取只含一个无符号整数的 cgroup 文件。
|
||||
func readCgroupUint(path string) (uint64, bool) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64)
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
// readCgroupStatValue 从 "key value" 逐行格式的 cgroup 统计文件(cpu.stat、memory.stat)中读取一项。
|
||||
func readCgroupStatValue(path string, key string) (uint64, bool) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
for line := range strings.SplitSeq(string(raw), "\n") {
|
||||
name, valueText, found := strings.Cut(line, " ")
|
||||
if !found || name != key {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(valueText), 10, 64)
|
||||
return value, err == nil
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -62,9 +62,12 @@ func encode(writer io.Writer, event CustomEvent) error {
|
||||
}
|
||||
|
||||
func writeData(w stringWriter, data any) error {
|
||||
dataReplacer.WriteString(w, fmt.Sprint(data))
|
||||
if _, err := dataReplacer.WriteString(w, fmt.Sprint(data)); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasPrefix(data.(string), "data") {
|
||||
w.writeString("\n\n")
|
||||
_, err := w.writeString("\n\n")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -76,6 +76,20 @@ func newSMTPClient(addr string) (*smtp.Client, error) {
|
||||
}
|
||||
|
||||
func SendEmail(subject string, receiver string, content string) error {
|
||||
OptionMapRWMutex.RLock()
|
||||
provider := OptionMap["EmailProvider"]
|
||||
resendKey := OptionMap["ResendApiKey"]
|
||||
resendFrom := OptionMap["ResendFrom"]
|
||||
OptionMapRWMutex.RUnlock()
|
||||
// One selector controls delivery. Never fall back to a second provider:
|
||||
// a failed response does not prove that the first provider did not send.
|
||||
switch provider {
|
||||
case "resend":
|
||||
return sendResendEmail(resendKey, resendFrom, subject, receiver, content)
|
||||
case "", "smtp": // Preserve existing installations without a selector.
|
||||
default:
|
||||
return NewMessage("Email sending method must be SMTP or Resend")
|
||||
}
|
||||
if SMTPFrom == "" { // for compatibility
|
||||
SMTPFrom = SMTPAccount
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var resendEmailClient = &http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
// ValidateResendConfiguration is shared by provider activation and delivery.
|
||||
// It checks local configuration only; saving settings never sends an email.
|
||||
func ValidateResendConfiguration(apiKey, from string) error {
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if apiKey == "" || strings.ContainsAny(apiKey, "\r\n\x00") {
|
||||
return NewMessage("Resend API key is not configured or invalid")
|
||||
}
|
||||
if strings.ContainsAny(from, "\r\n\x00") {
|
||||
return NewMessage("Resend sender address is not configured or invalid")
|
||||
}
|
||||
if _, err := mail.ParseAddress(strings.TrimSpace(from)); err != nil {
|
||||
return NewMessage("Resend sender address is not configured or invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// API contract: https://resend.com/docs/api-reference/emails/send-email
|
||||
// OWASP Authentication / Session Management / Forgot Password cheat sheets:
|
||||
// do not expose credentials, verification codes or reset links in errors/logs.
|
||||
// This transport does not change token expiry, single-use or session rules.
|
||||
func sendResendEmail(apiKey, from, subject, receiver, content string) error {
|
||||
if err := ValidateResendConfiguration(apiKey, from); err != nil {
|
||||
return err
|
||||
}
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
sender, _ := mail.ParseAddress(strings.TrimSpace(from))
|
||||
if sender.Name == "" {
|
||||
sender.Name = SystemName
|
||||
}
|
||||
to := make([]string, 0)
|
||||
for address := range strings.SplitSeq(receiver, ";") {
|
||||
recipient, err := mail.ParseAddress(strings.TrimSpace(address))
|
||||
if err != nil || len(to) >= 50 {
|
||||
return NewMessage("Invalid email address")
|
||||
}
|
||||
to = append(to, recipient.Address)
|
||||
}
|
||||
payload, err := Marshal(struct {
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
HTML string `json:"html"`
|
||||
}{sender.String(), to, subject, content})
|
||||
if err != nil {
|
||||
return NewMessage("Resend request failed")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return NewMessage("Resend request failed")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := resendEmailClient.Do(req)
|
||||
if err != nil {
|
||||
SysError(LogText("Resend request failed: error=%q", err))
|
||||
return NewMessage("Resend request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// The reason (unverified domain, invalid key, quota) goes to the server
|
||||
// log only, because the error may reach whoever asked for the email.
|
||||
var failure struct {
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = DecodeJson(io.LimitReader(resp.Body, 4096), &failure)
|
||||
SysError(LogText("Resend rejected the email: status=%d name=%q message=%q", resp.StatusCode, failure.Name, failure.Message))
|
||||
return NewMessage("Resend email delivery failed (HTTP {{status}})", map[string]any{"status": resp.StatusCode})
|
||||
}
|
||||
var result struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := DecodeJson(io.LimitReader(resp.Body, 4096), &result); err != nil || strings.TrimSpace(result.ID) == "" {
|
||||
return NewMessage("Invalid Resend delivery response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -21,6 +21,8 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
|
||||
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
|
||||
constant.EndpointTypeOpenAIResponseCompact: {Path: "/v1/responses/compact", Method: "POST"},
|
||||
constant.EndpointTypeOpenAIAlphaSearch: {Path: "/v1/alpha/search", Method: "POST"},
|
||||
constant.EndpointTypeDeepSeekToolsSearch: {Path: "/v1/tools/search", Method: "POST"},
|
||||
constant.EndpointTypeDeepSeekToolsFetch: {Path: "/v1/tools/fetch", Method: "POST"},
|
||||
constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
|
||||
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
|
||||
constant.EndpointTypeJinaRerank: {Path: "/v1/rerank", Method: "POST"},
|
||||
|
||||
@@ -4,6 +4,13 @@ import "github.com/QuantumNous/new-api/constant"
|
||||
|
||||
// GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点)
|
||||
func GetEndpointTypesByChannelType(channelType int, modelName string) []constant.EndpointType {
|
||||
// The DeepSeek Search and Fetch routing names serve only their endpoint.
|
||||
switch modelName {
|
||||
case constant.DeepSeekSearchModel:
|
||||
return []constant.EndpointType{constant.EndpointTypeDeepSeekToolsSearch}
|
||||
case constant.DeepSeekFetchModel:
|
||||
return []constant.EndpointType{constant.EndpointTypeDeepSeekToolsFetch}
|
||||
}
|
||||
var endpointTypes []constant.EndpointType
|
||||
switch channelType {
|
||||
case constant.ChannelTypeJina:
|
||||
|
||||
+2
-2
@@ -198,7 +198,7 @@ func initConstantEnv() {
|
||||
constant.TaskPluginEnabled = GetEnvOrDefaultBool("TASK_PLUGIN_ENABLED", true)
|
||||
constant.AzureDefaultAPIVersion = GetEnvOrDefaultString("AZURE_DEFAULT_API_VERSION", "2025-04-01-preview")
|
||||
constant.NotifyLimitCount = GetEnvOrDefault("NOTIFY_LIMIT_COUNT", 2)
|
||||
constant.NotificationLimitDurationMinute = GetEnvOrDefault("NOTIFICATION_LIMIT_DURATION_MINUTE", 10)
|
||||
constant.NotificationLimitDurationMinute = positiveUserSessionEnv("NOTIFICATION_LIMIT_DURATION_MINUTE", 10)
|
||||
// GenerateDefaultToken 是否生成初始令牌,默认关闭。
|
||||
constant.GenerateDefaultToken = GetEnvOrDefaultBool("GENERATE_DEFAULT_TOKEN", false)
|
||||
// 是否启用错误日志
|
||||
@@ -206,7 +206,7 @@ func initConstantEnv() {
|
||||
// 任务轮询时查询的最大数量
|
||||
constant.TaskQueryLimit = GetEnvOrDefault("TASK_QUERY_LIMIT", 1000)
|
||||
// 异步任务超时时间(分钟),超过此时间未完成的任务将被标记为失败并退款。0 表示禁用。
|
||||
constant.TaskTimeoutMinutes = GetEnvOrDefault("TASK_TIMEOUT_MINUTES", 1440)
|
||||
constant.TaskTimeoutMinutes = GetEnvOrDefault("TASK_TIMEOUT_MINUTES", 4320)
|
||||
// Consecutive unrecognized/transient poll failures before the task is failed and refunded.
|
||||
constant.TaskPollMaxFailures = GetEnvOrDefault("TASK_POLL_MAX_FAILURES", 20)
|
||||
// 声明式任务协议桥只观察数据库;这些值控制一次客户端观察连接,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -63,6 +64,20 @@ func (rl *RedisLimiter) Allow(ctx context.Context, key string, opts ...Option) (
|
||||
).Int()
|
||||
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "NOSCRIPT ") {
|
||||
result, err := rl.client.Eval(
|
||||
ctx,
|
||||
rateLimitScript,
|
||||
[]string{key},
|
||||
config.Requested,
|
||||
config.Rate,
|
||||
config.Capacity,
|
||||
).Int()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("rate limit failed: %w", err)
|
||||
}
|
||||
return result == 1, nil
|
||||
}
|
||||
return false, fmt.Errorf("rate limit failed: %w", err)
|
||||
}
|
||||
return result == 1, nil
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package limiter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/go-redis/redis/v8"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func useLimiterMiniRedis(t *testing.T) (*miniredis.Miniredis, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
previousInstance := instance
|
||||
|
||||
redisServer := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
|
||||
require.NoError(t, redisClient.Ping(context.Background()).Err())
|
||||
|
||||
instance = nil
|
||||
once = sync.Once{}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = redisClient.Close()
|
||||
instance = previousInstance
|
||||
once = sync.Once{}
|
||||
if previousInstance != nil {
|
||||
once.Do(func() {})
|
||||
}
|
||||
})
|
||||
|
||||
return redisServer, redisClient
|
||||
}
|
||||
|
||||
func TestAllowFallsBackToEvalAfterScriptFlush(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, redisClient := useLimiterMiniRedis(t)
|
||||
|
||||
redisLimiter := New(ctx, redisClient)
|
||||
|
||||
allowed, err := redisLimiter.Allow(
|
||||
ctx,
|
||||
"rateLimit:test-before-flush",
|
||||
WithCapacity(60),
|
||||
WithRate(60),
|
||||
WithRequested(60),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed)
|
||||
|
||||
exists, err := redisClient.ScriptExists(ctx, redisLimiter.limitScriptSHA).Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, exists, 1)
|
||||
assert.True(t, exists[0])
|
||||
|
||||
require.NoError(t, redisClient.ScriptFlush(ctx).Err())
|
||||
|
||||
exists, err = redisClient.ScriptExists(ctx, redisLimiter.limitScriptSHA).Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, exists, 1)
|
||||
assert.False(t, exists[0])
|
||||
|
||||
allowed, err = redisLimiter.Allow(
|
||||
ctx,
|
||||
"rateLimit:test-after-flush",
|
||||
WithCapacity(60),
|
||||
WithRate(60),
|
||||
WithRequested(60),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed)
|
||||
|
||||
exists, err = redisClient.ScriptExists(ctx, redisLimiter.limitScriptSHA).Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, exists, 1)
|
||||
assert.True(t, exists[0])
|
||||
}
|
||||
@@ -148,6 +148,8 @@
|
||||
"RemoveGeminiDisabledFields Marshal error: %s": "RemoveGeminiDisabledFields 序列化失败: %s",
|
||||
"RemoveGeminiDisabledFields Unmarshal error: %s": "RemoveGeminiDisabledFields 反序列化失败: %s",
|
||||
"Rerank request body: %s": "重排序请求体: %s",
|
||||
"Resend rejected the email: status=%d name=%q message=%q": "Resend 拒绝发送邮件: status=%d name=%q message=%q",
|
||||
"Resend request failed: error=%q": "请求 Resend 失败: error=%q",
|
||||
"SKIP_64BIT_QUOTA_SCHEMA_CHECK=true; skipping user quota schema check": "已设置 SKIP_64BIT_QUOTA_SCHEMA_CHECK=true,跳过用户额度字段的类型检查",
|
||||
"SQL_DSN not set, using SQLite as database": "未设置 SQL_DSN,数据库使用 SQLite",
|
||||
"SSE ping data sent": "SSE ping 数据已发送",
|
||||
@@ -531,8 +533,6 @@
|
||||
"failed to marshal setting: channel_id=%d, error=%v": "序列化渠道设置失败: channel_id=%d, error=%v",
|
||||
"failed to migrate options primary key: %s": "为 options 表补主键失败: %s",
|
||||
"failed to migrate retired frontend options: %s": "迁移旧前端的配置项失败: %s",
|
||||
"failed to notify root user: %s": "通知 root 用户失败: %s",
|
||||
"failed to notify user %d for upstream model update: %s": "向用户 %d 发送上游模型更新通知失败: %s",
|
||||
"failed to open log file": "打开日志文件失败",
|
||||
"failed to parse %s: %s, using default value: %d": "环境变量 %s 解析失败: %s,已使用默认值: %d",
|
||||
"failed to parse %s: %s, using default value: %t": "环境变量 %s 解析失败: %s,已使用默认值: %t",
|
||||
@@ -543,10 +543,10 @@
|
||||
"failed to populate newly created user session cache: %s": "新建登录会话后写入缓存失败: %s",
|
||||
"failed to publish auth tombstone after hard deleting user %d: %v": "永久删除用户 %d 后发布登录失效通知失败: %v",
|
||||
"failed to publish websocket close event: %v": "向其他节点广播 WebSocket 关闭事件失败: %v",
|
||||
"failed to query administrators to notify: %s": "查询需要通知的管理员失败: %s",
|
||||
"failed to query channels: %s": "查询渠道失败: %s",
|
||||
"failed to query log stat: %s": "查询日志统计失败: %s",
|
||||
"failed to query rpm/tpm stat: %s": "查询 rpm/tpm 统计失败: %s",
|
||||
"failed to query upstream update notification users: %s": "查询接收上游更新通知的用户失败: %s",
|
||||
"failed to read TTS response body: %v": "读取 TTS 响应体失败: %v",
|
||||
"failed to record log: %s": "记录日志失败: %s",
|
||||
"failed to record task billing log: %s": "记录任务计费日志失败: %s",
|
||||
@@ -564,6 +564,7 @@
|
||||
"failed to search topups: %s": "搜索充值记录失败: %s",
|
||||
"failed to search user logs: %s": "搜索用户日志失败: %s",
|
||||
"failed to send email to %s: %v": "向 %s 发送邮件失败: %v",
|
||||
"failed to send notice %s to user %d: %s": "向用户 %[2]d 发送通知 %[1]s 失败: %[3]s",
|
||||
"failed to send password reset email to %s: %s": "向 %s 发送密码重置邮件失败: %s",
|
||||
"failed to send quota notify to user %d: %s": "向用户 %d 发送额度通知失败: %s",
|
||||
"failed to send request: %s": "发送请求失败: %s",
|
||||
|
||||
@@ -26,3 +26,13 @@ func TestWanEndpointsDistinguishImagesFromVideos(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// /v1/models reports the DeepSeek Search and Fetch routing names with their
|
||||
// own endpoint on the channels that relay them; chat models keep their list.
|
||||
func TestDeepSeekToolRoutingNamesReportTheirEndpoint(t *testing.T) {
|
||||
for _, channelType := range []int{constant.ChannelTypeDeepSeek, constant.ChannelTypeNewAPI} {
|
||||
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeDeepSeekToolsSearch}, common.GetEndpointTypesByChannelType(channelType, constant.DeepSeekSearchModel))
|
||||
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeDeepSeekToolsFetch}, common.GetEndpointTypesByChannelType(channelType, constant.DeepSeekFetchModel))
|
||||
}
|
||||
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, common.GetEndpointTypesByChannelType(constant.ChannelTypeDeepSeek, "deepseek-flash"))
|
||||
}
|
||||
+96
-17
@@ -1,6 +1,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -25,10 +26,36 @@ type SystemStatus struct {
|
||||
CPUUsage float64
|
||||
MemoryUsage float64
|
||||
DiskUsage float64
|
||||
SampledAt time.Time
|
||||
// 过载判定带连续性要求:单一采样尖峰不再直接熔断,需连续多次超阈值。
|
||||
CPUOverloaded bool
|
||||
MemoryOverloaded bool
|
||||
DiskOverloaded bool
|
||||
}
|
||||
|
||||
// SystemOverload 是哨兵据以拒绝请求的过载项,Error 文本带当前值与生效阈值。
|
||||
type SystemOverload struct {
|
||||
Code string
|
||||
Resource string
|
||||
Usage float64
|
||||
Threshold int
|
||||
}
|
||||
|
||||
func (o *SystemOverload) Error() string {
|
||||
return fmt.Sprintf("system %s overloaded (current: %.1f%%, threshold: %d%%)", o.Resource, o.Usage, o.Threshold)
|
||||
}
|
||||
|
||||
var latestSystemStatus atomic.Value
|
||||
|
||||
const (
|
||||
systemMonitorInterval = 5 * time.Second
|
||||
// 连续 3 次采样(约 15 秒)仍高于阈值才判定过载,
|
||||
// 任一采样回落到阈值以下即恢复,避免瞬时尖峰打满重试。
|
||||
overloadConsecutiveSamples = 3
|
||||
// 采样超过该时长未刷新(监控未运行或被阻塞)时,哨兵不据此熔断。
|
||||
systemStatusMaxAge = 30 * time.Second
|
||||
)
|
||||
|
||||
func init() {
|
||||
latestSystemStatus.Store(SystemStatus{})
|
||||
}
|
||||
@@ -36,46 +63,98 @@ func init() {
|
||||
// StartSystemMonitor 启动系统监控
|
||||
func StartSystemMonitor() {
|
||||
go func() {
|
||||
usage := newContainerUsage()
|
||||
var detector overloadDetector
|
||||
for {
|
||||
config := GetPerformanceMonitorConfig()
|
||||
if !config.Enabled {
|
||||
time.Sleep(30 * time.Second)
|
||||
detector = overloadDetector{}
|
||||
latestSystemStatus.Store(SystemStatus{SampledAt: time.Now()})
|
||||
time.Sleep(systemMonitorInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
updateSystemStatus()
|
||||
time.Sleep(5 * time.Second)
|
||||
status := collectSystemStatus(usage)
|
||||
detector.mark(&status, config)
|
||||
latestSystemStatus.Store(status)
|
||||
|
||||
time.Sleep(systemMonitorInterval)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func updateSystemStatus() {
|
||||
var status SystemStatus
|
||||
// overloadDetector 为每项资源累计连续超阈值的采样次数:连续 overloadConsecutiveSamples 次
|
||||
// 才标记过载,任一次回落立即清零并重新累计。
|
||||
type overloadDetector struct {
|
||||
cpuSamples int
|
||||
memorySamples int
|
||||
diskSamples int
|
||||
}
|
||||
|
||||
// CPU
|
||||
// 注意:cpu.Percent(0, false) 返回自上次调用以来的 CPU 使用率
|
||||
// 如果是第一次调用,可能会返回错误或不准确的值,但在循环中会逐渐正常
|
||||
percents, err := cpu.Percent(0, false)
|
||||
if err == nil && len(percents) > 0 {
|
||||
func (d *overloadDetector) mark(status *SystemStatus, config PerformanceMonitorConfig) {
|
||||
status.CPUOverloaded = countOverloadSample(&d.cpuSamples, exceedsThreshold(status.CPUUsage, config.CPUThreshold))
|
||||
status.MemoryOverloaded = countOverloadSample(&d.memorySamples, exceedsThreshold(status.MemoryUsage, config.MemoryThreshold))
|
||||
status.DiskOverloaded = countOverloadSample(&d.diskSamples, exceedsThreshold(status.DiskUsage, config.DiskThreshold))
|
||||
}
|
||||
|
||||
func countOverloadSample(samples *int, over bool) bool {
|
||||
if !over {
|
||||
*samples = 0
|
||||
return false
|
||||
}
|
||||
*samples = min(*samples+1, overloadConsecutiveSamples)
|
||||
return *samples == overloadConsecutiveSamples
|
||||
}
|
||||
|
||||
// exceedsThreshold 判断采样值是否超过阈值;阈值 <= 0 表示不检查该项。
|
||||
func exceedsThreshold(usage float64, threshold int) bool {
|
||||
return threshold > 0 && int(usage) > threshold
|
||||
}
|
||||
|
||||
func collectSystemStatus(usage *containerUsage) SystemStatus {
|
||||
now := time.Now()
|
||||
status := SystemStatus{SampledAt: now}
|
||||
|
||||
// CPU / 内存优先按容器 cgroup 配额计算;容器口径不可用时回退宿主机口径,
|
||||
// 保持对未限制资源或被挂载为宿主 cgroup 的环境的兼容。
|
||||
if saturation, ok := usage.cpuSaturation(now); ok {
|
||||
status.CPUUsage = saturation
|
||||
} else if percents, err := cpu.Percent(0, false); err == nil && len(percents) > 0 {
|
||||
status.CPUUsage = percents[0]
|
||||
}
|
||||
|
||||
// Memory
|
||||
memInfo, err := mem.VirtualMemory()
|
||||
if err == nil {
|
||||
if containerMemory, ok := usage.memoryUsage(); ok {
|
||||
status.MemoryUsage = containerMemory
|
||||
} else if memInfo, err := mem.VirtualMemory(); err == nil {
|
||||
status.MemoryUsage = memInfo.UsedPercent
|
||||
}
|
||||
|
||||
// Disk
|
||||
diskInfo := GetDiskSpaceInfo()
|
||||
if diskInfo.Total > 0 {
|
||||
status.DiskUsage = diskInfo.UsedPercent
|
||||
}
|
||||
|
||||
latestSystemStatus.Store(status)
|
||||
return status
|
||||
}
|
||||
|
||||
// GetSystemStatus 获取当前系统状态
|
||||
func GetSystemStatus() SystemStatus {
|
||||
return latestSystemStatus.Load().(SystemStatus)
|
||||
}
|
||||
|
||||
// CheckSystemOverload 返回哨兵应据以熔断的过载项。监控关闭、采样缺失或已超过
|
||||
// systemStatusMaxAge 时返回 nil。过载标记来自连续采样,阈值按当前配置复核,
|
||||
// 运行时关闭或调高阈值后立即放行,不必等待下一次采样。
|
||||
func CheckSystemOverload(status SystemStatus, config PerformanceMonitorConfig, now time.Time) *SystemOverload {
|
||||
if !config.Enabled || status.SampledAt.IsZero() || now.Sub(status.SampledAt) > systemStatusMaxAge {
|
||||
return nil
|
||||
}
|
||||
if status.CPUOverloaded && exceedsThreshold(status.CPUUsage, config.CPUThreshold) {
|
||||
return &SystemOverload{Code: "system_cpu_overloaded", Resource: "cpu", Usage: status.CPUUsage, Threshold: config.CPUThreshold}
|
||||
}
|
||||
if status.MemoryOverloaded && exceedsThreshold(status.MemoryUsage, config.MemoryThreshold) {
|
||||
return &SystemOverload{Code: "system_memory_overloaded", Resource: "memory", Usage: status.MemoryUsage, Threshold: config.MemoryThreshold}
|
||||
}
|
||||
if status.DiskOverloaded && exceedsThreshold(status.DiskUsage, config.DiskThreshold) {
|
||||
return &SystemOverload{Code: "system_disk_overloaded", Resource: "disk", Usage: status.DiskUsage, Threshold: config.DiskThreshold}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newTestContainerUsage 在临时目录中构造 cgroup 文件,避免依赖运行环境的真实 cgroup。
|
||||
func newTestContainerUsage(t *testing.T, schedulableCPUs float64, files map[string]string) *containerUsage {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for name, content := range files {
|
||||
writeTestCgroupFile(t, filepath.Join(root, name), content)
|
||||
}
|
||||
return &containerUsage{cgroupRoot: root, schedulableCPUs: schedulableCPUs}
|
||||
}
|
||||
|
||||
func writeTestCgroupFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
||||
}
|
||||
|
||||
func TestContainerCPUSaturation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
schedulableCPUs float64
|
||||
files map[string]string
|
||||
usageFile string
|
||||
usageAfter string
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "cgroup v2 quota",
|
||||
schedulableCPUs: 4,
|
||||
files: map[string]string{"cpu.max": "200000 100000\n", "cpu.stat": "usage_usec 1000000\nuser_usec 900000\n"},
|
||||
usageFile: "cpu.stat",
|
||||
usageAfter: "usage_usec 6000000\n", // 5 秒用 1 核,配额 2 核
|
||||
want: 50,
|
||||
},
|
||||
{
|
||||
name: "fractional quota",
|
||||
schedulableCPUs: 4,
|
||||
files: map[string]string{"cpu.max": "50000 100000\n", "cpu.stat": "usage_usec 1000000\n"},
|
||||
usageFile: "cpu.stat",
|
||||
usageAfter: "usage_usec 3500000\n", // 5 秒用 0.5 核,配额 0.5 核
|
||||
want: 100,
|
||||
},
|
||||
{
|
||||
name: "quota above schedulable CPUs",
|
||||
schedulableCPUs: 1,
|
||||
files: map[string]string{"cpu.max": "200000 100000\n", "cpu.stat": "usage_usec 1000000\n"},
|
||||
usageFile: "cpu.stat",
|
||||
usageAfter: "usage_usec 6000000\n", // cpuset 只有 1 核,已打满
|
||||
want: 100,
|
||||
},
|
||||
{
|
||||
name: "cgroup v1 quota",
|
||||
schedulableCPUs: 4,
|
||||
files: map[string]string{
|
||||
"cpu/cpu.cfs_quota_us": "200000\n",
|
||||
"cpu/cpu.cfs_period_us": "100000\n",
|
||||
"cpuacct/cpuacct.usage": "1000000000\n",
|
||||
},
|
||||
usageFile: "cpuacct/cpuacct.usage",
|
||||
usageAfter: "11000000000\n", // 纳秒:5 秒用 2 核
|
||||
want: 100,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
usage := newTestContainerUsage(t, tt.schedulableCPUs, tt.files)
|
||||
base := time.Date(2026, 9, 11, 3, 0, 0, 0, time.UTC)
|
||||
_, ok := usage.cpuSaturation(base)
|
||||
assert.False(t, ok, "首次采样只建立基线")
|
||||
|
||||
writeTestCgroupFile(t, filepath.Join(usage.cgroupRoot, tt.usageFile), tt.usageAfter)
|
||||
got, ok := usage.cpuSaturation(base.Add(5 * time.Second))
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, tt.want, got, 0.01)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerCPUSaturationFallsBackWithoutQuota(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
}{
|
||||
{name: "cgroup v2 unlimited", files: map[string]string{"cpu.max": "max 100000\n", "cpu.stat": "usage_usec 1000000\n"}},
|
||||
{name: "cgroup v1 unlimited", files: map[string]string{"cpu/cpu.cfs_quota_us": "-1\n", "cpu/cpu.cfs_period_us": "100000\n", "cpuacct/cpuacct.usage": "1000\n"}},
|
||||
{name: "no cgroup files"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
usage := newTestContainerUsage(t, 4, tt.files)
|
||||
base := time.Date(2026, 9, 11, 3, 0, 0, 0, time.UTC)
|
||||
usage.cpuSaturation(base)
|
||||
_, ok := usage.cpuSaturation(base.Add(5 * time.Second))
|
||||
assert.False(t, ok, "没有配额时交由调用方回退宿主机口径")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerMemoryUsageUsesWorkingSet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "cgroup v2 excludes inactive file cache",
|
||||
files: map[string]string{
|
||||
"memory.current": "266338304\n",
|
||||
"memory.max": "268435456\n",
|
||||
"memory.stat": "anon 4435968\nfile 253362176\ninactive_file 251662336\n",
|
||||
},
|
||||
want: 5.4672,
|
||||
},
|
||||
{
|
||||
name: "cgroup v2 high working set keeps protection",
|
||||
files: map[string]string{
|
||||
"memory.current": "255852544\n",
|
||||
"memory.max": "268435456\n",
|
||||
"memory.stat": "anon 254803968\ninactive_file 1048576\n",
|
||||
},
|
||||
want: 94.9219,
|
||||
},
|
||||
{
|
||||
name: "cgroup v1 uses hierarchical inactive file cache",
|
||||
files: map[string]string{
|
||||
"memory/memory.usage_in_bytes": "805306368\n",
|
||||
"memory/memory.limit_in_bytes": "1610612736\n",
|
||||
"memory/memory.stat": "inactive_file 999\ntotal_inactive_file 268435456\n",
|
||||
},
|
||||
want: 33.3333,
|
||||
},
|
||||
{
|
||||
name: "cache larger than usage does not underflow",
|
||||
files: map[string]string{
|
||||
"memory.current": "1000\n",
|
||||
"memory.max": "268435456\n",
|
||||
"memory.stat": "inactive_file 2000\n",
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := newTestContainerUsage(t, 4, tt.files).memoryUsage()
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, tt.want, got, 0.001)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerMemoryUsageFallsBackWhenUnavailable(t *testing.T) {
|
||||
limited := map[string]string{"memory.current": "266338304\n", "memory.max": "268435456\n"}
|
||||
tests := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
stat string
|
||||
}{
|
||||
{name: "cgroup v2 unlimited", files: map[string]string{"memory.current": "1000\n", "memory.max": "max\n"}, stat: "inactive_file 0\n"},
|
||||
{name: "cgroup v1 unlimited", files: map[string]string{
|
||||
"memory/memory.usage_in_bytes": "1000\n",
|
||||
"memory/memory.limit_in_bytes": "9223372036854771712\n",
|
||||
"memory/memory.stat": "total_inactive_file 0\n",
|
||||
}},
|
||||
{name: "memory.stat missing", files: limited},
|
||||
{name: "inactive file field missing", files: limited, stat: "anon 4435968\n"},
|
||||
{name: "inactive file field corrupt", files: limited, stat: "inactive_file abc\n"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
usage := newTestContainerUsage(t, 4, tt.files)
|
||||
if tt.stat != "" {
|
||||
writeTestCgroupFile(t, filepath.Join(usage.cgroupRoot, "memory.stat"), tt.stat)
|
||||
}
|
||||
_, ok := usage.memoryUsage()
|
||||
assert.False(t, ok, "拿不到可信工作集时交由调用方回退宿主机口径")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemOverloadRequiresConsecutiveSamples(t *testing.T) {
|
||||
config := PerformanceMonitorConfig{Enabled: true, CPUThreshold: 90, MemoryThreshold: 90, DiskThreshold: 95}
|
||||
now := time.Date(2026, 9, 11, 3, 0, 0, 0, time.UTC)
|
||||
cpuSamples := []struct {
|
||||
usage float64
|
||||
wantOverload bool
|
||||
}{
|
||||
{usage: 95, wantOverload: false},
|
||||
{usage: 95, wantOverload: false},
|
||||
{usage: 95, wantOverload: true},
|
||||
{usage: 95, wantOverload: true},
|
||||
{usage: 50, wantOverload: false}, // 回落即恢复
|
||||
{usage: 95, wantOverload: false}, // 重新累计
|
||||
{usage: 95, wantOverload: false},
|
||||
{usage: 95, wantOverload: true},
|
||||
}
|
||||
|
||||
var detector overloadDetector
|
||||
for i, sample := range cpuSamples {
|
||||
status := SystemStatus{CPUUsage: sample.usage, SampledAt: now}
|
||||
detector.mark(&status, config)
|
||||
overload := CheckSystemOverload(status, config, now)
|
||||
if !sample.wantOverload {
|
||||
assert.Nil(t, overload, "sample %d", i+1)
|
||||
continue
|
||||
}
|
||||
require.NotNil(t, overload, "sample %d", i+1)
|
||||
assert.Equal(t, "system_cpu_overloaded", overload.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSystemOverload(t *testing.T) {
|
||||
now := time.Date(2026, 9, 11, 3, 0, 0, 0, time.UTC)
|
||||
enabled := PerformanceMonitorConfig{Enabled: true, CPUThreshold: 90, MemoryThreshold: 90, DiskThreshold: 95}
|
||||
cpuOverloaded := SystemStatus{CPUUsage: 99.5, CPUOverloaded: true, SampledAt: now}
|
||||
tests := []struct {
|
||||
name string
|
||||
status SystemStatus
|
||||
config PerformanceMonitorConfig
|
||||
wantCode string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "fresh cpu overload",
|
||||
status: cpuOverloaded,
|
||||
config: enabled,
|
||||
wantCode: "system_cpu_overloaded",
|
||||
wantMessage: "system cpu overloaded (current: 99.5%, threshold: 90%)",
|
||||
},
|
||||
{
|
||||
name: "fresh memory overload",
|
||||
status: SystemStatus{MemoryUsage: 96.2, MemoryOverloaded: true, SampledAt: now},
|
||||
config: enabled,
|
||||
wantCode: "system_memory_overloaded",
|
||||
wantMessage: "system memory overloaded (current: 96.2%, threshold: 90%)",
|
||||
},
|
||||
{
|
||||
name: "fresh disk overload",
|
||||
status: SystemStatus{DiskUsage: 97, DiskOverloaded: true, SampledAt: now},
|
||||
config: enabled,
|
||||
wantCode: "system_disk_overloaded",
|
||||
wantMessage: "system disk overloaded (current: 97.0%, threshold: 95%)",
|
||||
},
|
||||
{
|
||||
name: "lowered threshold reports the active threshold",
|
||||
status: cpuOverloaded,
|
||||
config: PerformanceMonitorConfig{Enabled: true, CPUThreshold: 80},
|
||||
wantCode: "system_cpu_overloaded",
|
||||
wantMessage: "system cpu overloaded (current: 99.5%, threshold: 80%)",
|
||||
},
|
||||
{name: "monitor disabled", status: cpuOverloaded, config: PerformanceMonitorConfig{Enabled: false, CPUThreshold: 90}},
|
||||
{name: "cpu threshold disabled at runtime", status: cpuOverloaded, config: PerformanceMonitorConfig{Enabled: true, CPUThreshold: 0}},
|
||||
{name: "cpu threshold raised above usage", status: cpuOverloaded, config: PerformanceMonitorConfig{Enabled: true, CPUThreshold: 100}},
|
||||
{name: "stale sample", status: SystemStatus{CPUUsage: 99.5, CPUOverloaded: true, SampledAt: now.Add(-31 * time.Second)}, config: enabled},
|
||||
{name: "missing sample", status: SystemStatus{CPUUsage: 99.5, CPUOverloaded: true}, config: enabled},
|
||||
{name: "usage above threshold without consecutive samples", status: SystemStatus{CPUUsage: 99.5, SampledAt: now}, config: enabled},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
overload := CheckSystemOverload(tt.status, tt.config, now)
|
||||
if tt.wantCode == "" {
|
||||
assert.Nil(t, overload)
|
||||
return
|
||||
}
|
||||
require.NotNil(t, overload)
|
||||
assert.Equal(t, tt.wantCode, overload.Code)
|
||||
assert.Equal(t, tt.wantMessage, overload.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ const (
|
||||
EndpointTypeOpenAIResponse = types.EndpointTypeOpenAIResponse
|
||||
EndpointTypeOpenAIResponseCompact = types.EndpointTypeOpenAIResponseCompact
|
||||
EndpointTypeOpenAIAlphaSearch = types.EndpointTypeOpenAIAlphaSearch
|
||||
EndpointTypeDeepSeekToolsSearch = types.EndpointTypeDeepSeekToolsSearch
|
||||
EndpointTypeDeepSeekToolsFetch = types.EndpointTypeDeepSeekToolsFetch
|
||||
EndpointTypeAnthropic = types.EndpointTypeAnthropic
|
||||
EndpointTypeGemini = types.EndpointTypeGemini
|
||||
EndpointTypeJinaRerank = types.EndpointTypeJinaRerank
|
||||
@@ -18,3 +20,10 @@ const (
|
||||
EndpointTypeEmbeddings = types.EndpointTypeEmbeddings
|
||||
EndpointTypeOpenAIVideo = types.EndpointTypeOpenAIVideo
|
||||
)
|
||||
|
||||
// Model names the distributor assigns to /v1/tools/search and /v1/tools/fetch,
|
||||
// whose bodies carry no model. Channels serve the endpoints by listing them.
|
||||
const (
|
||||
DeepSeekSearchModel = "deepseek-search"
|
||||
DeepSeekFetchModel = "deepseek-fetch"
|
||||
)
|
||||
@@ -305,9 +305,16 @@ func getDeepSeekBalanceUSD(response DeepSeekUsageResponse, usdExchangeRate float
|
||||
if balance < 0 {
|
||||
return 0, errors.New("USD balance must be non-negative")
|
||||
}
|
||||
return balance, nil
|
||||
if balance > 0 {
|
||||
return balance, nil
|
||||
}
|
||||
}
|
||||
if cnyBalance == nil {
|
||||
// An empty USD wallet is a valid balance. Only report a missing
|
||||
// currency when the response carried neither USD nor CNY.
|
||||
if usdBalance != nil {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, errors.New("currency USD or CNY not found")
|
||||
}
|
||||
if math.IsNaN(usdExchangeRate) || math.IsInf(usdExchangeRate, 0) {
|
||||
|
||||
@@ -1090,8 +1090,9 @@ func performChannelTests(ctx context.Context, channels []*model.Channel, testUse
|
||||
// stops promptly. mode selects the channel set: an empty mode falls back to the
|
||||
// configured monitor ChannelTestMode (scheduled behavior), while a manual
|
||||
// trigger passes ChannelTestModeScheduledAll to test every channel. When notify
|
||||
// is set the root user is notified on completion. Cross-instance execution is
|
||||
// guarded by the system task per-type lock, so no process-local guard is needed.
|
||||
// is set, the administrators who receive channel notices are notified on
|
||||
// completion. Cross-instance execution is guarded by the system task per-type
|
||||
// lock, so no process-local guard is needed.
|
||||
func runChannelTestTask(ctx context.Context, mode string, notify bool, report func(processed, total int)) (channelTestSummary, error) {
|
||||
testUserID, err := resolveChannelTestUserID(nil)
|
||||
if err != nil {
|
||||
@@ -1109,8 +1110,9 @@ func runChannelTestTask(ctx context.Context, mode string, notify bool, report fu
|
||||
concurrency := operation_setting.GetMonitorSetting().ChannelTestConcurrency
|
||||
summary := performChannelTests(ctx, selected, testUserID, allowDisable, concurrency, report)
|
||||
if notify && (ctx == nil || ctx.Err() == nil) {
|
||||
lang := service.RootUserLanguage()
|
||||
service.NotifyRootUser(dto.NotifyTypeChannelTest, i18n.Translate(lang, i18n.MsgChannelTestCompletedSubject), i18n.Translate(lang, i18n.MsgChannelTestCompletedContent))
|
||||
service.NotifyChannelWatchers(dto.NotifyTypeChannelTest, func(lang string) (string, string) {
|
||||
return i18n.Translate(lang, i18n.MsgChannelTestCompletedSubject), i18n.Translate(lang, i18n.MsgChannelTestCompletedContent)
|
||||
})
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
@@ -39,6 +39,24 @@ func TestGetDeepSeekBalanceUSD(t *testing.T) {
|
||||
usdExchangeRate: 7.3,
|
||||
want: 10,
|
||||
},
|
||||
{
|
||||
name: "converts CNY when USD balance is zero",
|
||||
responseJSON: `{"balance_infos":[{"currency":"USD","total_balance":"0.00"},{"currency":"CNY","total_balance":"73.00"}]}`,
|
||||
usdExchangeRate: 7.3,
|
||||
want: 10,
|
||||
},
|
||||
{
|
||||
name: "converts CNY when zero USD follows CNY",
|
||||
responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"73.00"},{"currency":"USD","total_balance":"0.00"}]}`,
|
||||
usdExchangeRate: 7.3,
|
||||
want: 10,
|
||||
},
|
||||
{
|
||||
name: "returns zero when USD balance is zero and CNY is absent",
|
||||
responseJSON: `{"balance_infos":[{"currency":"USD","total_balance":"0.00"}]}`,
|
||||
usdExchangeRate: 7.3,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "returns error when USD and CNY are absent",
|
||||
responseJSON: `{"balance_infos":[{"currency":"EUR","total_balance":"10.00"}]}`,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetChannelPriorityOrder(c *gin.Context) {
|
||||
order, err := model.GetChannelPriorityOrder()
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": order})
|
||||
}
|
||||
|
||||
func ReorderChannelPriority(c *gin.Context) {
|
||||
var move model.ChannelPriorityMove
|
||||
if err := common.DecodeJson(c.Request.Body, &move); err != nil {
|
||||
common.ApiErrorT(c, "Invalid channel priority move")
|
||||
return
|
||||
}
|
||||
order, err := model.ReorderChannelPriority(move)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrChannelPriorityConflict) || errors.Is(err, model.ErrChannelPriorityInvalid) || errors.Is(err, model.ErrChannelPriorityRange) {
|
||||
common.ApiErrorT(c, err.Error())
|
||||
} else {
|
||||
common.ApiError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !move.Preview && len(order.Changes) > 0 {
|
||||
model.InitChannelCache()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": order})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/oauth"
|
||||
@@ -547,7 +548,7 @@ func UnbindCustomOAuth(c *gin.Context) {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
notificationFailed = service.NotifyAccountSecurityChange(user.Email, "Login account unlinked") != nil
|
||||
notificationFailed = service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginUnlinked) != nil
|
||||
|
||||
common.ApiSuccessT(c, "Unbound successfully", gin.H{"notification_warning": notificationFailed})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -46,7 +47,7 @@ func EmailBindStart(c *gin.Context) {
|
||||
if authorization == nil {
|
||||
return
|
||||
}
|
||||
data, err := service.StartEmailBinding(identity, authorization, email)
|
||||
data, err := service.StartEmailBinding(identity, authorization, email, service.AccountMailLanguage(c, identity.UserID))
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
@@ -70,7 +71,7 @@ func EmailBindResend(c *gin.Context) {
|
||||
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
|
||||
return
|
||||
}
|
||||
data, err := service.ResendAccountEmailBinding(identity, request.FlowToken)
|
||||
data, err := service.ResendAccountEmailBinding(identity, request.FlowToken, service.AccountMailLanguage(c, identity.UserID))
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
@@ -100,8 +101,9 @@ func EmailBind(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
succeeded = true
|
||||
notificationFailed = service.NotifyAccountSecurityChange(state.CurrentEmail, "Email address changed") != nil
|
||||
if err := service.NotifyAccountSecurityChange(state.Email, "Email address confirmed"); err != nil {
|
||||
lang := service.AccountMailLanguage(c, identity.UserID)
|
||||
notificationFailed = service.NotifyAccountSecurityChange(lang, state.CurrentEmail, i18n.MsgSecurityEventEmailChanged) != nil
|
||||
if err := service.NotifyAccountSecurityChange(lang, state.Email, i18n.MsgSecurityEventEmailConfirmed); err != nil {
|
||||
notificationFailed = true
|
||||
}
|
||||
if err := model.PublishUserAuthCache(identity.UserID); err != nil {
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type emailHTTPTransport func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (transport emailHTTPTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return transport(request)
|
||||
}
|
||||
|
||||
func configureEmailDelivery(t *testing.T, options map[string]string) {
|
||||
t.Helper()
|
||||
common.OptionMapRWMutex.Lock()
|
||||
original := common.OptionMap
|
||||
common.OptionMap = options
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
originalTransport := http.DefaultTransport
|
||||
originalFrom, originalAccount, originalServer := common.SMTPFrom, common.SMTPAccount, common.SMTPServer
|
||||
common.SMTPFrom, common.SMTPAccount, common.SMTPServer = "", "", ""
|
||||
t.Cleanup(func() {
|
||||
common.OptionMapRWMutex.Lock()
|
||||
common.OptionMap = original
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
http.DefaultTransport = originalTransport
|
||||
common.SMTPFrom, common.SMTPAccount, common.SMTPServer = originalFrom, originalAccount, originalServer
|
||||
})
|
||||
}
|
||||
|
||||
func TestResendDeliversThroughSelectedProvider(t *testing.T) {
|
||||
configureEmailDelivery(t, map[string]string{"EmailProvider": "resend", "ResendApiKey": "test-key", "ResendFrom": "New API <sender@example.com>"})
|
||||
calls := 0
|
||||
http.DefaultTransport = emailHTTPTransport(func(request *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
assert.Equal(t, "https://api.resend.com/emails", request.URL.String())
|
||||
assert.Equal(t, http.MethodPost, request.Method)
|
||||
assert.Equal(t, "Bearer test-key", request.Header.Get("Authorization"))
|
||||
assert.Equal(t, "application/json", request.Header.Get("Content-Type"))
|
||||
_, hasDeadline := request.Context().Deadline()
|
||||
assert.True(t, hasDeadline, "email delivery must have a bounded wait")
|
||||
var body struct {
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
require.NoError(t, common.DecodeJson(request.Body, &body))
|
||||
assert.Equal(t, `"New API" <sender@example.com>`, body.From)
|
||||
assert.Equal(t, []string{"one@example.com", "two@example.com"}, body.To)
|
||||
assert.Equal(t, "验证码", body.Subject)
|
||||
assert.Equal(t, "<p>123456</p>", body.HTML)
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"id":"email-test-id"}`)), Header: make(http.Header)}, nil
|
||||
})
|
||||
require.NoError(t, common.SendEmail("验证码", "one@example.com; two@example.com", "<p>123456</p>"))
|
||||
assert.Equal(t, 1, calls, "no SMTP configuration is needed and no duplicate request is made")
|
||||
}
|
||||
|
||||
func TestResendFailuresDoNotRetryFallbackOrExposeSensitiveContent(t *testing.T) {
|
||||
for _, status := range []int{302, 400, 401, 403, 429, 500} {
|
||||
t.Run(strconv.Itoa(status), func(t *testing.T) {
|
||||
configureEmailDelivery(t, map[string]string{"EmailProvider": "resend", "ResendApiKey": "test-key", "ResendFrom": "sender@example.com"})
|
||||
calls := 0
|
||||
http.DefaultTransport = emailHTTPTransport(func(request *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return &http.Response{StatusCode: status, Header: http.Header{"Location": []string{"https://untrusted.example/emails"}}, Body: io.NopCloser(strings.NewReader(`{"message":"test-key 123456 reset-token"}`))}, nil
|
||||
})
|
||||
err := common.SendEmail("verification", "one@example.com", "123456 reset-token")
|
||||
require.EqualError(t, err, "Resend email delivery failed (HTTP "+strconv.Itoa(status)+")")
|
||||
assert.Equal(t, 1, calls, "do not retry or follow redirects")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResendRejectsIncompleteOrInvalidConfigurationBeforeSending(t *testing.T) {
|
||||
tests := []struct{ name, key, from, receiver string }{
|
||||
{"missing key", "", "sender@example.com", "one@example.com"},
|
||||
{"header injection", "key\r\nInjected: value", "sender@example.com", "one@example.com"},
|
||||
{"missing sender", "test-key", "", "one@example.com"},
|
||||
{"invalid sender", "test-key", "bad-address", "one@example.com"},
|
||||
{"invalid recipient", "test-key", "sender@example.com", "bad-address"},
|
||||
{"empty recipient", "test-key", "sender@example.com", ""},
|
||||
{"too many recipients", "test-key", "sender@example.com", strings.Repeat("one@example.com;", 50) + "two@example.com"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
configureEmailDelivery(t, map[string]string{"EmailProvider": "resend", "ResendApiKey": test.key, "ResendFrom": test.from})
|
||||
http.DefaultTransport = emailHTTPTransport(func(*http.Request) (*http.Response, error) {
|
||||
t.Fatal("invalid settings must not make a request")
|
||||
return nil, errors.New("unexpected request")
|
||||
})
|
||||
err := common.SendEmail("verification", test.receiver, "123456")
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), "123456")
|
||||
assert.NotContains(t, err.Error(), "test-key")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResendRejectsMalformedSuccessAndSanitizesTransportErrors(t *testing.T) {
|
||||
for _, body := range []string{`{}`, `not json`, `{"id":false}`, `{"id":""}`} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
configureEmailDelivery(t, map[string]string{"EmailProvider": "resend", "ResendApiKey": "test-key", "ResendFrom": "sender@example.com"})
|
||||
http.DefaultTransport = emailHTTPTransport(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||
})
|
||||
require.EqualError(t, common.SendEmail("verification", "one@example.com", "123456"), "Invalid Resend delivery response")
|
||||
})
|
||||
}
|
||||
configureEmailDelivery(t, map[string]string{"EmailProvider": "resend", "ResendApiKey": "test-key", "ResendFrom": "sender@example.com"})
|
||||
http.DefaultTransport = emailHTTPTransport(func(*http.Request) (*http.Response, error) { return nil, errors.New("test-key 123456 reset-token") })
|
||||
require.EqualError(t, common.SendEmail("verification", "one@example.com", "123456"), "Resend request failed")
|
||||
}
|
||||
|
||||
func TestSMTPAndLegacyConfigurationNeverUseResend(t *testing.T) {
|
||||
for _, provider := range []string{"", "smtp", "invalid"} {
|
||||
t.Run("provider="+provider, func(t *testing.T) {
|
||||
configureEmailDelivery(t, map[string]string{"EmailProvider": provider, "ResendApiKey": "test-key", "ResendFrom": "sender@example.com"})
|
||||
http.DefaultTransport = emailHTTPTransport(func(*http.Request) (*http.Response, error) {
|
||||
t.Fatal("SMTP and unknown providers must not invoke Resend")
|
||||
return nil, errors.New("unexpected request")
|
||||
})
|
||||
require.Error(t, common.SendEmail("verification", "one@example.com", "123456"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailOptionsNeverReturnResendCredentials(t *testing.T) {
|
||||
configureEmailDelivery(t, map[string]string{"EmailProvider": "resend", "ResendApiKey": "private-email-credential", "ResendFrom": "sender@example.com"})
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = httptest.NewRequest(http.MethodGet, "/api/option/", nil)
|
||||
GetOptions(context)
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
assert.NotContains(t, response.Body.String(), "private-email-credential")
|
||||
assert.NotContains(t, response.Body.String(), "ResendApiKey")
|
||||
assert.Contains(t, response.Body.String(), "ResendFrom")
|
||||
assert.Contains(t, response.Body.String(), `"key":"ResendKeyConfigured","value":"true"`)
|
||||
}
|
||||
|
||||
func TestEmailProviderOptionRejectsInvalidValues(t *testing.T) {
|
||||
for _, provider := range []string{"", "both", "SMTP", "smtp,resend"} {
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
configureEmailDelivery(t, map[string]string{"EmailProvider": "smtp"})
|
||||
body, err := common.Marshal(OptionUpdateRequest{Key: "EmailProvider", Value: provider})
|
||||
require.NoError(t, err)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = httptest.NewRequest(http.MethodPut, "/api/option/", strings.NewReader(string(body)))
|
||||
UpdateOption(context)
|
||||
assert.Contains(t, response.Body.String(), `"success":false`)
|
||||
assert.Contains(t, response.Body.String(), `"message_key":"Email sending method must be SMTP or Resend"`)
|
||||
assert.Equal(t, "smtp", common.OptionMap["EmailProvider"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailProviderCannotActivateIncompleteResend(t *testing.T) {
|
||||
for name, settings := range map[string]map[string]string{
|
||||
"missing key": {"ResendFrom": "sender@example.com"},
|
||||
"missing sender": {"ResendApiKey": "test-key"},
|
||||
"invalid sender": {"ResendApiKey": "test-key", "ResendFrom": "invalid"},
|
||||
"invalid key": {"ResendApiKey": "key\r\nInjected: value", "ResendFrom": "sender@example.com"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
settings["EmailProvider"] = "smtp"
|
||||
configureEmailDelivery(t, settings)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = httptest.NewRequest(http.MethodPut, "/api/option/", strings.NewReader(`{"key":"EmailProvider","value":"resend"}`))
|
||||
require.NotPanics(t, func() { UpdateOption(context) }, "invalid activation must fail before accessing storage")
|
||||
assert.Contains(t, response.Body.String(), `"success":false`)
|
||||
assert.Contains(t, response.Body.String(), `"message_key":"Resend `)
|
||||
assert.Equal(t, "smtp", common.OptionMap["EmailProvider"])
|
||||
assert.NotContains(t, response.Body.String(), "test-key")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResendSettingsChangesKeepActiveDeliveryWorking(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Option{}))
|
||||
previousDB, previousRedis := model.DB, common.RedisEnabled
|
||||
model.DB, common.RedisEnabled = db, false
|
||||
t.Cleanup(func() { model.DB, common.RedisEnabled = previousDB, previousRedis })
|
||||
|
||||
tests := []struct {
|
||||
name, provider, key, value string
|
||||
saved bool
|
||||
}{
|
||||
{"active sender the parser rejects", "resend", "ResendFrom", "notify..team@example.com", false},
|
||||
{"active key cleared", "resend", "ResendApiKey", " ", false},
|
||||
{"active sender renamed", "resend", "ResendFrom", `"New API" <notify@example.com>`, true},
|
||||
{"inactive sender saved before its key", "smtp", "ResendFrom", "notify@example.com", true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
settings := map[string]string{"EmailProvider": test.provider, "ResendApiKey": "test-key", "ResendFrom": "sender@example.com"}
|
||||
if test.provider == "smtp" {
|
||||
settings["ResendApiKey"] = ""
|
||||
}
|
||||
configureEmailDelivery(t, settings)
|
||||
before := settings[test.key]
|
||||
body, err := common.Marshal(OptionUpdateRequest{Key: test.key, Value: test.value})
|
||||
require.NoError(t, err)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = httptest.NewRequest(http.MethodPut, "/api/option/", strings.NewReader(string(body)))
|
||||
UpdateOption(context)
|
||||
if test.saved {
|
||||
assert.Contains(t, response.Body.String(), `"success":true`)
|
||||
assert.Equal(t, test.value, common.OptionMap[test.key])
|
||||
return
|
||||
}
|
||||
assert.Contains(t, response.Body.String(), `"message_key":"Resend `)
|
||||
assert.Equal(t, before, common.OptionMap[test.key])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailKeyStatusIsDerivedAndReadOnly(t *testing.T) {
|
||||
configureEmailDelivery(t, map[string]string{"EmailProvider": "smtp", "ResendKeyConfigured": "true"})
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = httptest.NewRequest(http.MethodGet, "/api/option/", nil)
|
||||
GetOptions(context)
|
||||
assert.Contains(t, response.Body.String(), `"key":"ResendKeyConfigured","value":"false"`)
|
||||
assert.NotContains(t, response.Body.String(), `"value":"true"`)
|
||||
|
||||
response = httptest.NewRecorder()
|
||||
context, _ = gin.CreateTestContext(response)
|
||||
context.Request = httptest.NewRequest(http.MethodPut, "/api/option/", strings.NewReader(`{"key":"ResendKeyConfigured","value":true}`))
|
||||
UpdateOption(context)
|
||||
assert.Contains(t, response.Body.String(), `"success":false`)
|
||||
assert.Contains(t, response.Body.String(), "read-only")
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
|
||||
@@ -159,7 +160,7 @@ func completeVerifiedLoginResponse(c *gin.Context, bundle *service.AuthBundle, m
|
||||
c.Set("login_verification_method", method)
|
||||
if migration != nil {
|
||||
// The legacy GitHub binding was rewritten together with this session.
|
||||
notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: GitHub") != nil
|
||||
notificationFailed := service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": "GitHub"}) != nil
|
||||
recordLegacyGitHubBindingAudit(c, user, true, map[string]any{
|
||||
"legacy_id": migration.LegacyID, "provider_user_id": migration.GitHubID,
|
||||
"verification_method": method, "notification_failed": notificationFailed,
|
||||
|
||||
@@ -125,6 +125,8 @@ func GetStatus(c *gin.Context) {
|
||||
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
|
||||
"log_export_enabled": operation_setting.GetLogSetting().UserExportEnabled,
|
||||
"log_max_range_days": operation_setting.GetLogSetting().MaxRangeDays,
|
||||
"quota_remind_enabled": common.QuotaRemindEnabled,
|
||||
"quota_remind_threshold": common.QuotaRemindThreshold,
|
||||
}
|
||||
|
||||
// 根据启用状态注入可选内容
|
||||
|
||||
@@ -276,7 +276,8 @@ func TestModelPricingConversionDatabaseMatrix(t *testing.T) {
|
||||
}{
|
||||
{"unrecognized-model", nil, model.CacheWriteNone},
|
||||
{"gpt-5.6-terra", nil, model.CacheWriteNone},
|
||||
{"gpt-5.6-terra", common.GetPointer(1.25), model.CacheWriteStandard},
|
||||
{"gpt-5.6-terra", common.GetPointer(1.25), model.CacheWriteOpenAITTL},
|
||||
{"custom-GPT-alias", common.GetPointer(0.0), model.CacheWriteOpenAITTL},
|
||||
{"vendor/claude-sonnet-4-6-high", nil, model.CacheWriteClaudeTTL},
|
||||
{"custom-CLAUDE-alias", common.GetPointer(0.0), model.CacheWriteClaudeTTL},
|
||||
{"deepseek-reasoner", common.GetPointer(0.0), model.CacheWriteStandard},
|
||||
@@ -306,13 +307,19 @@ func TestModelPricingConversionDatabaseMatrix(t *testing.T) {
|
||||
vars := billingexpr.UsedVars(response.Data.Expression)
|
||||
assert.Equal(t, tc.mode != model.CacheWriteNone, vars["cc"])
|
||||
assert.Equal(t, tc.mode == model.CacheWriteClaudeTTL, vars["cc1h"])
|
||||
assert.Equal(t, tc.mode == model.CacheWriteOpenAITTL, vars["cc12h"])
|
||||
if tc.configured != nil && *tc.configured == 0 {
|
||||
cost, _, err := billingexpr.RunExpr(response.Data.Expression, billingexpr.TokenParams{CC: 100, CC1h: 100})
|
||||
cost, _, err := billingexpr.RunExpr(response.Data.Expression, billingexpr.TokenParams{CC: 100, CC1h: 100, CC12h: 100})
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, cost, "explicit zero cache prices must stay free")
|
||||
}
|
||||
})
|
||||
}
|
||||
// GPT names price the 12h write at twice the configured write price.
|
||||
openai, err := model.PreviewModelPricingConversion("gpt-5.6-terra", model.PricingValues{"ModelRatio": 5.0, "CompletionRatio": 5.0, "CacheRatio": 0.1, "CreateCacheRatio": 1.25})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.CacheWriteOpenAITTL, openai.CacheWriteMode)
|
||||
assert.Equal(t, `tier("base", p * 10 + c * 50 + cr * 1 + cc * 12.5 + cc12h * 25)`, openai.Expression)
|
||||
// The billing name remains authoritative for cache terms.
|
||||
require.NoError(t, (&model.Channel{Name: "Cache alias", Type: 1, Models: "opaque-cache-alias", ModelMapping: common.GetPointer(`{"opaque-cache-alias":"claude-sonnet-4-6"}`)}).Insert())
|
||||
alias, err := model.PreviewModelPricingConversion("opaque-cache-alias", model.PricingValues{"ModelRatio": 2.0})
|
||||
@@ -1525,6 +1532,12 @@ func TestSharedModelPluginPricingDatabaseMatrix(t *testing.T) {
|
||||
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(spec.key) })
|
||||
if spec.key == "matrix-alpha" {
|
||||
single, err := model.GetModelPricingSnapshot([]string{name})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, single.Entries[0].PluginVariants, 1, "a sole provider must expose its separate pricing editor")
|
||||
assert.Equal(t, "matrix-alpha", single.Entries[0].PluginVariants[0].PluginKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Representative existing options have no plugin-expression row.
|
||||
@@ -1560,6 +1573,21 @@ func TestSharedModelPluginPricingDatabaseMatrix(t *testing.T) {
|
||||
assert.Equal(t, base, loaded.Entries[0].PluginVariants[0].Effective)
|
||||
assert.True(t, loaded.Entries[0].PluginVariants[1].Compatible)
|
||||
assert.Equal(t, variant, loaded.Entries[0].PluginVariants[1].Configured)
|
||||
// Ordinary relay pricing and the existing plugin expressions are
|
||||
// saved together without changing either plugin's billing contract.
|
||||
ordinary := model.ModelPricingChange{ModelName: name, ExpectedVersion: loaded.Entries[0].Version, Pricing: model.PricingValues{
|
||||
"billing_setting.billing_mode": "tiered_expr", "billing_setting.billing_expr": `tier("image", fixed(0.04)) * image_count`,
|
||||
billing_setting.PluginBillingExprOption: map[string]any{"matrix-alpha": base, "matrix-beta": variant},
|
||||
}}
|
||||
require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{ordinary}))
|
||||
separate, err := model.GetModelPricingSnapshot([]string{name})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ordinary.Pricing, separate.Entries[0].Configured)
|
||||
assert.Equal(t, base, separate.Entries[0].PluginVariants[0].Effective)
|
||||
assert.Equal(t, variant, separate.Entries[0].PluginVariants[1].Effective)
|
||||
// Keep exercising the pre-existing plugin-default configuration below.
|
||||
change.ExpectedVersion = separate.Entries[0].Version
|
||||
require.NoError(t, model.UpdateModelPricing([]model.ModelPricingChange{change}))
|
||||
var flat map[string]string
|
||||
require.NoError(t, common.UnmarshalJsonStr(loaded.Options[billing_setting.PluginBillingExprOption], &flat))
|
||||
assert.Equal(t, map[string]string{"matrix-beta::" + name: variant}, flat)
|
||||
|
||||
+3
-2
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/oauth"
|
||||
@@ -387,7 +388,7 @@ func handleOAuthBind(c *gin.Context, providerName string, provider oauth.Provide
|
||||
writeSecurityOperationError(c, err)
|
||||
return true, true
|
||||
}
|
||||
notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: "+provider.GetName()) != nil
|
||||
notificationFailed := service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": provider.GetName()}) != nil
|
||||
common.ApiSuccessT(c, "Binding successful", gin.H{"action": "bind", "notification_warning": notificationFailed})
|
||||
return true, notificationFailed
|
||||
}
|
||||
@@ -464,7 +465,7 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
|
||||
}
|
||||
if written {
|
||||
user.GitHubId = oauthUser.ProviderUserID
|
||||
notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: "+provider.GetName()) != nil
|
||||
notificationFailed := service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": provider.GetName()}) != nil
|
||||
recordLegacyGitHubBindingAudit(c, user, true, map[string]any{
|
||||
"legacy_id": legacyID, "provider_user_id": oauthUser.ProviderUserID,
|
||||
"verified_email_matched": true, "notification_failed": notificationFailed,
|
||||
|
||||
@@ -85,7 +85,7 @@ func GetOptions(c *gin.Context) {
|
||||
optionValues := make(map[string]string)
|
||||
common.OptionMapRWMutex.Lock()
|
||||
for k, v := range common.OptionMap {
|
||||
if k == "theme.frontend" || k == "billing_setting.billing_mode" || k == "billing_setting.billing_expr" {
|
||||
if k == "theme.frontend" || k == "billing_setting.billing_mode" || k == "billing_setting.billing_expr" || k == "ResendKeyConfigured" {
|
||||
continue
|
||||
}
|
||||
value := common.Interface2String(v)
|
||||
@@ -105,6 +105,9 @@ func GetOptions(c *gin.Context) {
|
||||
optionValues[k] = value
|
||||
}
|
||||
}
|
||||
options = append(options, &model.Option{
|
||||
Key: "ResendKeyConfigured", Value: strconv.FormatBool(strings.TrimSpace(common.OptionMap["ResendApiKey"]) != ""),
|
||||
})
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
// Display the same effective expressions used by pricing and settlement,
|
||||
// including built-in defaults absent from persisted administrator options.
|
||||
|
||||
@@ -63,6 +63,10 @@ func (b *nativeRouteBilling) Reserve(quota int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReserveBeforeSubmit holds nothing: this fixture charges in Reserve once the
|
||||
// upstream has accepted.
|
||||
func (*nativeRouteBilling) ReserveBeforeSubmit(int) error { return nil }
|
||||
|
||||
func TestKlingNativeRouteSubmitPollSettleAndQuery(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
service.InitHttpClient()
|
||||
|
||||
@@ -643,7 +643,6 @@ func waitForTaskPluginProtocolTick(
|
||||
case <-observationContext.Done():
|
||||
return false
|
||||
case <-heartbeatTicker.C:
|
||||
helper.ExtendWriteDeadline(c)
|
||||
if err := writeTaskPluginProtocolHeartbeat(c); err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -1242,7 +1241,6 @@ func writeTaskPluginProtocolEvent(c *gin.Context, event dto.PluginResponsesStrea
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
helper.ExtendWriteDeadline(c)
|
||||
if _, err = c.Writer.Write([]byte("event: " + event.Type + "\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -209,7 +209,10 @@ func modelPricingSyncValues(data map[string]any, name string) map[string]any {
|
||||
}
|
||||
|
||||
func FetchUpstreamRatios(c *gin.Context) {
|
||||
var req dto.UpstreamRequest
|
||||
var req struct {
|
||||
dto.UpstreamRequest
|
||||
IncludeUnchanged bool `json:"include_unchanged"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.SysError(common.LogText("failed to bind upstream request: %s", err.Error()))
|
||||
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Invalid request parameter format"))
|
||||
@@ -602,10 +605,29 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
Current map[string]any `json:"current"`
|
||||
Upstreams map[string]map[string]any `json:"upstreams"`
|
||||
}
|
||||
prices := make(map[string]modelSyncPrices, len(differences))
|
||||
for name, fields := range differences {
|
||||
models := make(map[string]struct{}, len(differences))
|
||||
for name := range differences {
|
||||
models[name] = struct{}{}
|
||||
}
|
||||
if req.IncludeUnchanged {
|
||||
for _, channel := range successfulChannels {
|
||||
for _, field := range pricingSyncFields {
|
||||
for name := range valueMap(channel.data[field]) {
|
||||
models[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prices := make(map[string]modelSyncPrices, len(models))
|
||||
for name := range models {
|
||||
row := modelSyncPrices{Current: modelPricingSyncValues(localData, name), Upstreams: make(map[string]map[string]any)}
|
||||
_, expressionPriority := fields[billing_setting.BillingExprField]
|
||||
expressionPriority := valueMap(localData[billing_setting.BillingModeField])[name] == billing_setting.BillingModeTieredExpr
|
||||
for _, channel := range successfulChannels {
|
||||
if valueMap(channel.data[billing_setting.BillingModeField])[name] == billing_setting.BillingModeTieredExpr {
|
||||
expressionPriority = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, channel := range successfulChannels {
|
||||
candidate := modelPricingSyncValues(channel.data, name)
|
||||
if expressionPriority && candidate[billing_setting.BillingModeField] != billing_setting.BillingModeTieredExpr {
|
||||
@@ -618,7 +640,9 @@ func FetchUpstreamRatios(c *gin.Context) {
|
||||
row.Upstreams[channel.name] = candidate
|
||||
}
|
||||
}
|
||||
prices[name] = row
|
||||
if len(row.Upstreams) > 0 {
|
||||
prices[name] = row
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
@@ -139,4 +139,13 @@ func TestPricingSyncCompleteSourcesAndArrayFormats(t *testing.T) {
|
||||
assert.Equal(t, float64(4), response.Data.Prices["sync-token"].Upstreams["Expressions(1)"]["completion_ratio"])
|
||||
assert.Equal(t, float64(0), response.Data.Prices["sync-token"].Upstreams["Expressions(1)"]["cache_ratio"])
|
||||
assert.Equal(t, float64(0), response.Data.Prices["sync-free"].Upstreams["Legacy(2)"]["model_ratio"])
|
||||
assert.NotContains(t, response.Data.Prices, "sync-already")
|
||||
body["include_unchanged"] = true
|
||||
recorder = modelManagementRequest(t, FetchUpstreamRatios, http.MethodPost, "/api/ratio_sync/fetch", body, &response)
|
||||
require.True(t, response.Success, recorder.Body.String())
|
||||
assert.Equal(t, map[string]any{"billing_mode": "tiered_expr", "billing_expr": expression}, response.Data.Prices["sync-already"].Upstreams["Expressions(1)"])
|
||||
assert.NotContains(t, response.Data.Prices["sync-already"].Upstreams, "Legacy(2)")
|
||||
assert.NotContains(t, response.Data.Prices, "sync-unpriced")
|
||||
assert.NotContains(t, response.Data.Prices, "sync-invalid-expression")
|
||||
assert.NotContains(t, response.Data.Differences, "sync-already")
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
@@ -50,6 +51,8 @@ func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIErro
|
||||
err = relay.ResponsesHelper(c, info)
|
||||
case relayconstant.RelayModeAlphaSearch:
|
||||
err = relay.AlphaSearchHelper(c, info)
|
||||
case relayconstant.RelayModeToolsSearch, relayconstant.RelayModeToolsFetch:
|
||||
err = relay.DeepSeekToolsHelper(c, info)
|
||||
default:
|
||||
err = relay.TextHelper(c, info)
|
||||
}
|
||||
@@ -93,6 +96,14 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
logger.LogError(c, common.LogText("relay error: %s", common.LocalLogPreview(newAPIError.Error())))
|
||||
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
|
||||
upstreamURLs := common.UpstreamAddresses(c)
|
||||
if relayFormat != types.RelayFormatOpenAIRealtime && c.Writer.Written() &&
|
||||
strings.HasPrefix(c.Writer.Header().Get("Content-Type"), "text/event-stream") {
|
||||
writeRelayStreamError(c, relayFormat, newAPIError)
|
||||
return
|
||||
}
|
||||
if relayFormat != types.RelayFormatOpenAIRealtime {
|
||||
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
switch relayFormat {
|
||||
case types.RelayFormatOpenAIRealtime:
|
||||
helper.WssError(c, ws, newAPIError.ToOpenAIError(upstreamURLs...))
|
||||
@@ -140,6 +151,12 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
}
|
||||
}()
|
||||
|
||||
generalSettings := operation_setting.GetGeneralSetting()
|
||||
if relayFormat == types.RelayFormatClaude && relayInfo.IsStream && generalSettings.PingIntervalEnabled {
|
||||
stopKeepAlive := helper.StartStreamKeepAlive(c, time.Duration(generalSettings.PingIntervalSeconds)*time.Second)
|
||||
defer stopKeepAlive()
|
||||
}
|
||||
|
||||
if newAPIError = relay.PrepareRequestBilling(c, relayInfo); newAPIError != nil {
|
||||
return
|
||||
}
|
||||
@@ -222,6 +239,23 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeRelayStreamError(c *gin.Context, format types.RelayFormat, apiError *types.NewAPIError) {
|
||||
// 心跳提交响应头后,最终错误继续通过同一个流返回。
|
||||
status, _ := common.GetContextKeyType[*relaycommon.StreamStatus](c, constant.ContextKeyResponseStreamStatus)
|
||||
if status == nil {
|
||||
status = relaycommon.NewStreamStatus()
|
||||
common.SetContextKey(c, constant.ContextKeyResponseStreamStatus, status)
|
||||
}
|
||||
status.MarkFailed(string(apiError.GetErrorCode()), string(apiError.GetErrorType()), apiError.StatusCode)
|
||||
upstreamURLs := common.UpstreamAddresses(c)
|
||||
if format == types.RelayFormatClaude {
|
||||
_ = helper.ClaudeData(c, dto.ClaudeResponse{Type: "error", Error: apiError.ToClaudeError(upstreamURLs...)})
|
||||
return
|
||||
}
|
||||
_ = helper.ObjectData(c, gin.H{"error": apiError.ToOpenAIError(upstreamURLs...)})
|
||||
helper.Done(c)
|
||||
}
|
||||
|
||||
// CountClaudeTokens implements Anthropic's token-counting utility endpoint.
|
||||
// It deliberately skips upstream generation and billing; callers use this
|
||||
// endpoint to size prompts before creating a Message.
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRelayKeepAliveDuringImagePreparation(t *testing.T) {
|
||||
settings := operation_setting.GetGeneralSetting()
|
||||
previousEnabled, previousInterval := settings.PingIntervalEnabled, settings.PingIntervalSeconds
|
||||
previousCount, previousMedia := constant.CountToken, constant.GetMediaToken
|
||||
fetchSettings := system_setting.GetFetchSetting()
|
||||
previousFetchSettings := *fetchSettings
|
||||
fetchSettings.AllowPrivateIp = true
|
||||
fetchSettings.AllowedPorts = []string{"1-65535"}
|
||||
settings.PingIntervalEnabled, settings.PingIntervalSeconds = true, 1
|
||||
constant.CountToken, constant.GetMediaToken = true, true
|
||||
t.Cleanup(func() {
|
||||
settings.PingIntervalEnabled, settings.PingIntervalSeconds = previousEnabled, previousInterval
|
||||
constant.CountToken, constant.GetMediaToken = previousCount, previousMedia
|
||||
*fetchSettings = previousFetchSettings
|
||||
})
|
||||
service.InitHttpClient()
|
||||
|
||||
imageRequested := make(chan struct{})
|
||||
releaseImage := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
close(imageRequested)
|
||||
<-releaseImage
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer imageServer.Close()
|
||||
engine := gin.New()
|
||||
engine.POST("/v1/messages", func(c *gin.Context) {
|
||||
common.SetContextKey(c, constant.ContextKeyOriginalModel, "claude-sonnet-4-20250514")
|
||||
Relay(c, types.RelayFormatClaude)
|
||||
})
|
||||
gateway := httptest.NewServer(engine)
|
||||
defer gateway.Close()
|
||||
defer releaseOnce.Do(func() { close(releaseImage) })
|
||||
|
||||
body := `{"model":"claude-sonnet-4-20250514","max_tokens":16,"stream":true,"messages":[{"role":"user","content":[{"type":"image","source":{"type":"url","url":"` + imageServer.URL + `"}}]}]}`
|
||||
client := &http.Client{Timeout: 4 * time.Second}
|
||||
response, err := client.Post(gateway.URL+"/v1/messages", "application/json", strings.NewReader(body))
|
||||
require.NoError(t, err, "the gateway must send keepalive while its image download is blocked")
|
||||
defer response.Body.Close()
|
||||
require.Equal(t, http.StatusOK, response.StatusCode)
|
||||
require.Equal(t, "text/event-stream", response.Header.Get("Content-Type"))
|
||||
reader := bufio.NewReader(response.Body)
|
||||
for range 2 {
|
||||
line, readErr := reader.ReadString('\n')
|
||||
require.NoError(t, readErr)
|
||||
require.Equal(t, ": PING\n", line)
|
||||
line, readErr = reader.ReadString('\n')
|
||||
require.NoError(t, readErr)
|
||||
require.Equal(t, "\n", line)
|
||||
}
|
||||
select {
|
||||
case <-imageRequested:
|
||||
default:
|
||||
t.Fatal("the request never reached image preparation")
|
||||
}
|
||||
releaseOnce.Do(func() { close(releaseImage) })
|
||||
remaining, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(remaining), "event: error\n")
|
||||
require.Contains(t, string(remaining), `"type":"error"`)
|
||||
require.Contains(t, string(remaining), "data: ")
|
||||
}
|
||||
|
||||
func TestRelayKeepAliveAcrossUpstreamRetries(t *testing.T) {
|
||||
settings := operation_setting.GetGeneralSetting()
|
||||
previousEnabled, previousInterval := settings.PingIntervalEnabled, settings.PingIntervalSeconds
|
||||
settings.PingIntervalEnabled, settings.PingIntervalSeconds = true, 1
|
||||
t.Cleanup(func() {
|
||||
settings.PingIntervalEnabled, settings.PingIntervalSeconds = previousEnabled, previousInterval
|
||||
})
|
||||
service.InitHttpClient()
|
||||
var attempts atomic.Int32
|
||||
retryStarted, release := make(chan struct{}), make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if attempts.Add(1) == 1 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
close(retryStarted)
|
||||
select {
|
||||
case <-release:
|
||||
_, _ = io.WriteString(w, "data: ok\n\n")
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
engine := gin.New()
|
||||
engine.POST("/v1/messages", func(c *gin.Context) {
|
||||
stop := helper.StartStreamKeepAlive(c, 10*time.Millisecond)
|
||||
defer stop()
|
||||
if !assert.NoError(t, http.NewResponseController(c.Writer).SetWriteDeadline(time.Now().Add(5*time.Second))) {
|
||||
return
|
||||
}
|
||||
info := &relaycommon.RelayInfo{IsStream: true, ChannelMeta: &relaycommon.ChannelMeta{}}
|
||||
for range 2 {
|
||||
request, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, upstream.URL, strings.NewReader("{}"))
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
response, err := channel.DoRequest(c, request, info)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
if response.StatusCode == http.StatusTooManyRequests {
|
||||
response.Body.Close()
|
||||
continue
|
||||
}
|
||||
_, _ = io.Copy(c.Writer, response.Body)
|
||||
response.Body.Close()
|
||||
}
|
||||
})
|
||||
gateway := httptest.NewServer(engine)
|
||||
defer gateway.Close()
|
||||
defer releaseOnce.Do(func() { close(release) })
|
||||
client := &http.Client{Timeout: 4 * time.Second}
|
||||
response, err := client.Post(gateway.URL+"/v1/messages", "application/json", strings.NewReader("{}"))
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
select {
|
||||
case <-retryStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("the upstream retry did not start")
|
||||
}
|
||||
reader := bufio.NewReader(response.Body)
|
||||
for range 2 {
|
||||
line, readErr := reader.ReadString('\n')
|
||||
require.NoError(t, readErr)
|
||||
require.Equal(t, ": PING\n", line)
|
||||
line, readErr = reader.ReadString('\n')
|
||||
require.NoError(t, readErr)
|
||||
require.Equal(t, "\n", line)
|
||||
}
|
||||
releaseOnce.Do(func() { close(release) })
|
||||
body, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(body), "data: ok\n\n")
|
||||
require.Equal(t, int32(2), attempts.Load())
|
||||
}
|
||||
|
||||
func TestRelayKeepAlivePreservesValidationErrors(t *testing.T) {
|
||||
settings := operation_setting.GetGeneralSetting()
|
||||
previousEnabled := settings.PingIntervalEnabled
|
||||
settings.PingIntervalEnabled = true
|
||||
t.Cleanup(func() { settings.PingIntervalEnabled = previousEnabled })
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"claude-sonnet-4-20250514","stream":true}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
Relay(c, types.RelayFormatClaude)
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Contains(t, recorder.Header().Get("Content-Type"), "application/json")
|
||||
require.NotContains(t, recorder.Body.String(), ": PING")
|
||||
}
|
||||
|
||||
func TestStreamKeepAlivePreservesFastHTTPFailures(t *testing.T) {
|
||||
settings := operation_setting.GetGeneralSetting()
|
||||
previousEnabled, previousInterval := settings.PingIntervalEnabled, settings.PingIntervalSeconds
|
||||
settings.PingIntervalEnabled, settings.PingIntervalSeconds = true, 60
|
||||
t.Cleanup(func() {
|
||||
settings.PingIntervalEnabled, settings.PingIntervalSeconds = previousEnabled, previousInterval
|
||||
})
|
||||
service.InitHttpClient()
|
||||
for _, status := range []int{http.StatusUnauthorized, http.StatusPaymentRequired, http.StatusForbidden, http.StatusTooManyRequests} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, `{"error":"request rejected"}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
engine := gin.New()
|
||||
engine.POST("/v1/messages", func(c *gin.Context) {
|
||||
stop := helper.StartStreamKeepAlive(c, time.Minute)
|
||||
defer stop()
|
||||
request, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, upstream.URL, strings.NewReader("{}"))
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
info := &relaycommon.RelayInfo{IsStream: true, ChannelMeta: &relaycommon.ChannelMeta{}}
|
||||
response, err := channel.DoRequest(c, request, info)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if assert.NoError(t, err) {
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.Data(response.StatusCode, "application/json", body)
|
||||
}
|
||||
})
|
||||
gateway := httptest.NewServer(engine)
|
||||
defer gateway.Close()
|
||||
client := &http.Client{Timeout: 4 * time.Second}
|
||||
response, err := client.Post(gateway.URL+"/v1/messages", "application/json", strings.NewReader("{}"))
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, status, response.StatusCode)
|
||||
require.Equal(t, "application/json", response.Header.Get("Content-Type"))
|
||||
require.JSONEq(t, `{"error":"request rejected"}`, string(body))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayKeepAliveHonorsStreamSettings(t *testing.T) {
|
||||
settings := operation_setting.GetGeneralSetting()
|
||||
previousEnabled := settings.PingIntervalEnabled
|
||||
previousCount, previousMedia, previousNonStreamMedia := constant.CountToken, constant.GetMediaToken, constant.GetMediaTokenNotStream
|
||||
constant.CountToken, constant.GetMediaToken, constant.GetMediaTokenNotStream = true, true, true
|
||||
t.Cleanup(func() {
|
||||
settings.PingIntervalEnabled = previousEnabled
|
||||
constant.CountToken, constant.GetMediaToken, constant.GetMediaTokenNotStream = previousCount, previousMedia, previousNonStreamMedia
|
||||
})
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
enabled bool
|
||||
stream bool
|
||||
}{
|
||||
{name: "ping disabled", enabled: false, stream: true},
|
||||
{name: "non-streaming", enabled: true, stream: false},
|
||||
{name: "fast streaming failure", enabled: true, stream: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
settings.PingIntervalEnabled = test.enabled
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
body := `{"model":"claude-sonnet-4-20250514","max_tokens":16,"stream":` + strconv.FormatBool(test.stream) + `,"messages":[{"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"!"}}]}]}`
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
common.SetContextKey(c, constant.ContextKeyOriginalModel, "claude-sonnet-4-20250514")
|
||||
Relay(c, types.RelayFormatClaude)
|
||||
require.Equal(t, http.StatusInternalServerError, recorder.Code)
|
||||
require.Contains(t, recorder.Header().Get("Content-Type"), "application/json")
|
||||
require.NotContains(t, recorder.Body.String(), ": PING")
|
||||
var response map[string]any
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
require.Equal(t, "error", response["type"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayStreamErrorAfterKeepAlive(t *testing.T) {
|
||||
for _, format := range []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatOpenAI} {
|
||||
t.Run(string(format), func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/stream", nil)
|
||||
helper.SetEventStreamHeaders(c)
|
||||
require.NoError(t, helper.PingData(c))
|
||||
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, "https://provider.example")
|
||||
common.SetContextKey(c, constant.ContextKeyUpstreamRequestOrigin, "https://provider.example")
|
||||
apiError := types.NewErrorWithStatusCode(errors.New("upstream rejected input at https://provider.example/v1/messages"), types.ErrorCodeBadResponseStatusCode, http.StatusBadRequest)
|
||||
writeRelayStreamError(c, format, apiError)
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
|
||||
require.Contains(t, recorder.Body.String(), "upstream rejected input")
|
||||
require.NotContains(t, recorder.Body.String(), "provider.example")
|
||||
status, ok := common.GetContextKeyType[*relaycommon.StreamStatus](c, constant.ContextKeyResponseStreamStatus)
|
||||
require.True(t, ok)
|
||||
require.True(t, status.ResponseFailed(), "stream errors must not consume the successful-request rate limit")
|
||||
require.Equal(t, http.StatusBadRequest, status.OutcomeSnapshot().ErrorStatus)
|
||||
if format == types.RelayFormatClaude {
|
||||
require.Contains(t, recorder.Body.String(), "event: error\n")
|
||||
} else {
|
||||
require.Contains(t, recorder.Body.String(), "data: [DONE]")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamKeepAliveCancellation(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
requestContext, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/stream", nil).WithContext(requestContext)
|
||||
stop := helper.StartStreamKeepAlive(c, time.Hour)
|
||||
cancel()
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
stop()
|
||||
close(stopped)
|
||||
}()
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("keepalive did not stop after client cancellation")
|
||||
}
|
||||
_, err := c.Writer.WriteString("data: done\n\n")
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasSuffix(recorder.Body.String(), "data: done\n\n"))
|
||||
}
|
||||
@@ -58,6 +58,8 @@ func (b *taskSubmissionTestBilling) Reserve(int) error {
|
||||
return b.reserveErr
|
||||
}
|
||||
|
||||
func (b *taskSubmissionTestBilling) ReserveBeforeSubmit(quota int) error { return b.Reserve(quota) }
|
||||
|
||||
func TestPresentTaskSubmissionUsesNativePresenterAfterPersistence(t *testing.T) {
|
||||
plugin, err := pluginruntime.CompilePlugin(`
|
||||
export const meta = {apiVersion:1,key:"presenter-test",name:"Presenter",version:"1.0.0",author:{name:"Test"},models:["model"],fetchMode:"per_task",routes:[{method:"POST",path:"/vendor/jobs",type:"submit",decode:"decode",render:"created"}]};
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/textproto"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -21,8 +22,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/oauth"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -642,7 +645,9 @@ func TestSecurityAccountEmailConfirmationAndAudit(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(encoded), "user.binding_bind")
|
||||
assert.NotContains(t, string(encoded), flow.FlowToken)
|
||||
assert.NotContains(t, string(encoded), newCode)
|
||||
// Audit event and request IDs are long runs of time digits that can
|
||||
// contain the six-digit code by chance; only the whole code is a leak.
|
||||
assert.NotRegexp(t, `(^|\D)`+newCode+`(\D|$)`, string(encoded))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -651,7 +656,7 @@ func TestSecurityAccountEmailResendAndAttemptLimit(t *testing.T) {
|
||||
_, identity := setupSecurityEnrollmentTest(t)
|
||||
mailbox := newSecurityMailbox(t)
|
||||
flow := startSecurityEmailBinding(t, identity, "new@example.com", service.VerificationMethodPassword)
|
||||
_, err := service.ResendAccountEmailBinding(identity, flow.FlowToken)
|
||||
_, err := service.ResendAccountEmailBinding(identity, flow.FlowToken, "")
|
||||
assert.ErrorIs(t, err, model.ErrEmailBindingResendWait)
|
||||
_, err = service.FinishEmailBinding(identity, flow.FlowToken, "invalid", "")
|
||||
assert.ErrorIs(t, err, model.ErrEmailBindingCodeInvalid)
|
||||
@@ -662,7 +667,7 @@ func TestSecurityAccountEmailResendAndAttemptLimit(t *testing.T) {
|
||||
payload, err := common.Marshal(state)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, model.DB.Model(stored).Update("payload", string(payload)).Error)
|
||||
replacement, err := service.ResendAccountEmailBinding(identity, flow.FlowToken)
|
||||
replacement, err := service.ResendAccountEmailBinding(identity, flow.FlowToken, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, flow.ExpiresAt, replacement.ExpiresAt)
|
||||
_, state, err = model.GetEmailBinding(identity, flow.FlowToken)
|
||||
@@ -682,6 +687,56 @@ func TestSecurityAccountEmailResendAndAttemptLimit(t *testing.T) {
|
||||
assert.ErrorIs(t, err, model.ErrEmailBindingLocked)
|
||||
}
|
||||
|
||||
// Email binding codes and account security notices are written in the account
|
||||
// owner's saved language, else in the language the request states, and quote
|
||||
// provider names HTML-escaped.
|
||||
func TestSecurityAccountMailsFollowTheOwnersLanguage(t *testing.T) {
|
||||
user, identity := setupSecurityEnrollmentTest(t)
|
||||
mailbox := newSecurityMailbox(t)
|
||||
mails := func(receiver string) string {
|
||||
mailbox.mutex.Lock()
|
||||
defer mailbox.mutex.Unlock()
|
||||
return strings.Join(mailbox.mail[receiver], "\n")
|
||||
}
|
||||
saveLanguage := func(language string) {
|
||||
setting, err := common.Marshal(dto.UserSetting{Language: language})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, model.DB.Model(user).Update("setting", string(setting)).Error)
|
||||
}
|
||||
|
||||
saveLanguage("zhCN")
|
||||
require.NoError(t, model.DB.Model(user).Update("email", "previous@example.com").Error)
|
||||
flow := startSecurityEmailBinding(t, identity, "new@example.com", service.VerificationMethodPassword)
|
||||
request, err := common.Marshal(map[string]string{"flow_token": flow.FlowToken, "new_code": mailbox.code(t, flow.Email), "old_code": mailbox.code(t, "previous@example.com")})
|
||||
require.NoError(t, err)
|
||||
response := securityEnrollmentRequest("POST", "/api/oauth/email/bind", string(request), "", identity, EmailBind)
|
||||
require.Contains(t, response.Body.String(), `"success":true`)
|
||||
assert.Contains(t, mails("new@example.com"), "请确认将此邮箱地址绑定到您的账户。")
|
||||
assert.Contains(t, mails("new@example.com"), "您的账户安全设置有变动:邮箱地址已确认。")
|
||||
assert.Contains(t, mails("previous@example.com"), "您的账户正在申请更换邮箱地址,请确认替换当前邮箱。")
|
||||
assert.Contains(t, mails("previous@example.com"), "您的账户安全设置有变动:邮箱地址已更换。")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, saved, acceptLanguage, receiver, want string
|
||||
}{
|
||||
{"request language when none is saved", "", "zh-TW", "tw@example.com", "您的帳戶安全設定有變動:已綁定登入帳號 <b>Corp</b>。"},
|
||||
{"saved language over the request", "en", "zh-CN", "en@example.com", "Your account security settings have changed: Login account linked: <b>Corp</b>."},
|
||||
{"default language when neither states one", "", "", "default@example.com", "Your account security settings have changed: Login account linked: <b>Corp</b>."},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
saveLanguage(tc.saved)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/oauth/github", nil)
|
||||
if tc.acceptLanguage != "" {
|
||||
c.Request.Header.Set("Accept-Language", tc.acceptLanguage)
|
||||
}
|
||||
lang := service.AccountMailLanguage(c, user.Id)
|
||||
require.NoError(t, service.NotifyAccountSecurityChange(lang, tc.receiver, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": "<b>Corp</b>"}))
|
||||
assert.Contains(t, mails(tc.receiver), tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityAccountEmailConcurrentClaimsHaveOneOwner(t *testing.T) {
|
||||
user, firstIdentity := setupSecurityEnrollmentTest(t)
|
||||
mailbox := newSecurityMailbox(t)
|
||||
|
||||
+5
-4
@@ -234,10 +234,11 @@ func initTaskArtifactAdaptor(task *model.Task) (relaychannel.TaskAdaptor, error)
|
||||
}
|
||||
adaptor.Init(&relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
ChannelType: channelModel.Type,
|
||||
ChannelBaseUrl: baseURL,
|
||||
ApiKey: pluginKey,
|
||||
ChannelSetting: channelModel.GetSetting(),
|
||||
ChannelType: channelModel.Type,
|
||||
ChannelBaseUrl: baseURL,
|
||||
ApiKey: pluginKey,
|
||||
ChannelSetting: channelModel.GetSetting(),
|
||||
HeadersOverride: channelModel.GetHeaderOverride(),
|
||||
},
|
||||
})
|
||||
return adaptor, nil
|
||||
|
||||
@@ -355,6 +355,8 @@ func TestDisabledArtifactStorePreservesPluginUpstreamContent(t *testing.T) {
|
||||
task := setupGenericTaskTest(t)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "provider-key", r.Header.Get("x-goog-api-key"))
|
||||
assert.Equal(t, "provider-key", r.Header.Get("X-API-Key"), "channel header overrides reach credentialed downloads")
|
||||
assert.NotEqual(t, "ignored.example", r.Host)
|
||||
assert.Equal(t, "bytes=0-13", r.Header.Get("Range"))
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.Header().Set("Content-Range", "bytes 0-13/14")
|
||||
@@ -368,9 +370,10 @@ func TestDisabledArtifactStorePreservesPluginUpstreamContent(t *testing.T) {
|
||||
t.Cleanup(func() { common.MemoryCacheEnabled = previousMemoryCache })
|
||||
|
||||
require.NoError(t, model.DB.Model(&model.Channel{}).Where("id = ?", task.ChannelId).Updates(map[string]any{
|
||||
"type": constant.ChannelTypeGemini,
|
||||
"key": "provider-key",
|
||||
"base_url": upstream.URL,
|
||||
"type": constant.ChannelTypeGemini,
|
||||
"key": "provider-key",
|
||||
"base_url": upstream.URL,
|
||||
"header_override": `{"X-API-Key":"{api_key}","Host":"ignored.example"}`,
|
||||
}).Error)
|
||||
task.Platform = constant.TaskPlatform("google")
|
||||
task.PrivateData.Execution = &model.TaskExecutionSnapshot{TaskPlugin: &model.TaskPluginSnapshot{
|
||||
@@ -444,6 +447,8 @@ func TestTaskArtifactContentFollowsHailuoRetrieveLink(t *testing.T) {
|
||||
"type": constant.ChannelTypeMiniMax,
|
||||
"key": "provider-key",
|
||||
"base_url": upstream.URL,
|
||||
// Neither utils.fetch nor the credentialless OSS link gets overrides.
|
||||
"header_override": `{"Authorization":"Bearer override"}`,
|
||||
}).Error)
|
||||
task.Platform = constant.TaskPlatform("hailuo")
|
||||
task.PrivateData.Execution = &model.TaskExecutionSnapshot{TaskPlugin: &model.TaskPluginSnapshot{
|
||||
|
||||
+33
-33
@@ -896,7 +896,7 @@ func UpdateSelf(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
succeeded = true
|
||||
notificationFailed = service.NotifyAccountSecurityChange(current.Email, "Password updated") != nil
|
||||
notificationFailed = service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, current.Id), current.Email, i18n.MsgSecurityEventPasswordUpdated) != nil
|
||||
if err := model.PublishUserAuthCache(cleanUser.Id); err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
@@ -1318,6 +1318,7 @@ func TopUp(c *gin.Context) {
|
||||
type UpdateUserSettingRequest struct {
|
||||
QuotaWarningType string `json:"notify_type"`
|
||||
QuotaWarningThreshold float64 `json:"quota_warning_threshold"`
|
||||
QuotaWarningEnabled *bool `json:"quota_warning_enabled,omitempty"`
|
||||
WebhookUrl string `json:"webhook_url,omitempty"`
|
||||
WebhookSecret string `json:"webhook_secret,omitempty"`
|
||||
NotificationEmail string `json:"notification_email,omitempty"`
|
||||
@@ -1326,6 +1327,7 @@ type UpdateUserSettingRequest struct {
|
||||
GotifyToken string `json:"gotify_token,omitempty"`
|
||||
GotifyPriority int `json:"gotify_priority,omitempty"`
|
||||
UpstreamModelUpdateNotifyEnabled *bool `json:"upstream_model_update_notify_enabled,omitempty"`
|
||||
ChannelNotifyEnabled *bool `json:"channel_notify_enabled,omitempty"`
|
||||
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
|
||||
RecordIpLog bool `json:"record_ip_log"`
|
||||
}
|
||||
@@ -1343,9 +1345,9 @@ func UpdateUserSetting(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证预警阈值
|
||||
if req.QuotaWarningThreshold <= 0 {
|
||||
common.ApiErrorT(c, "Warning threshold must be greater than 0")
|
||||
// 验证预警阈值,0 表示使用系统默认阈值
|
||||
if req.QuotaWarningThreshold < 0 {
|
||||
common.ApiErrorT(c, "Reminder threshold cannot be negative")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1417,41 +1419,39 @@ func UpdateUserSetting(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
existingSettings := user.GetSetting()
|
||||
upstreamModelUpdateNotifyEnabled := existingSettings.UpstreamModelUpdateNotifyEnabled
|
||||
if user.Role >= common.RoleAdminUser && req.UpstreamModelUpdateNotifyEnabled != nil {
|
||||
upstreamModelUpdateNotifyEnabled = *req.UpstreamModelUpdateNotifyEnabled
|
||||
// 从已保存的设置出发,语言、侧边栏、扣费策略等不由此表单管理的字段保持不变;
|
||||
// 请求未携带的开关也保持原值
|
||||
settings := user.GetSetting()
|
||||
settings.NotifyType = req.QuotaWarningType
|
||||
settings.QuotaWarningThreshold = req.QuotaWarningThreshold
|
||||
settings.AcceptUnsetRatioModel = req.AcceptUnsetModelRatioModel
|
||||
settings.RecordIpLog = req.RecordIpLog
|
||||
if req.QuotaWarningEnabled != nil {
|
||||
settings.QuotaWarningEnabled = req.QuotaWarningEnabled
|
||||
}
|
||||
|
||||
// 构建设置
|
||||
settings := dto.UserSetting{
|
||||
NotifyType: req.QuotaWarningType,
|
||||
QuotaWarningThreshold: req.QuotaWarningThreshold,
|
||||
UpstreamModelUpdateNotifyEnabled: upstreamModelUpdateNotifyEnabled,
|
||||
AcceptUnsetRatioModel: req.AcceptUnsetModelRatioModel,
|
||||
RecordIpLog: req.RecordIpLog,
|
||||
}
|
||||
|
||||
// 如果是webhook类型,添加webhook相关设置
|
||||
if req.QuotaWarningType == dto.NotifyTypeWebhook {
|
||||
settings.WebhookUrl = req.WebhookUrl
|
||||
if req.WebhookSecret != "" {
|
||||
settings.WebhookSecret = req.WebhookSecret
|
||||
if user.Role >= common.RoleAdminUser {
|
||||
if req.UpstreamModelUpdateNotifyEnabled != nil {
|
||||
settings.UpstreamModelUpdateNotifyEnabled = *req.UpstreamModelUpdateNotifyEnabled
|
||||
}
|
||||
if req.ChannelNotifyEnabled != nil {
|
||||
settings.ChannelNotifyEnabled = req.ChannelNotifyEnabled
|
||||
}
|
||||
}
|
||||
|
||||
// 如果提供了通知邮箱,添加到设置中
|
||||
if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" {
|
||||
// 只保留所选通知方式的配置,其他方式的配置清空
|
||||
settings.WebhookUrl, settings.WebhookSecret = "", ""
|
||||
settings.NotificationEmail = ""
|
||||
settings.BarkUrl = ""
|
||||
settings.GotifyUrl, settings.GotifyToken, settings.GotifyPriority = "", "", 0
|
||||
switch req.QuotaWarningType {
|
||||
case dto.NotifyTypeWebhook:
|
||||
settings.WebhookUrl = req.WebhookUrl
|
||||
settings.WebhookSecret = req.WebhookSecret
|
||||
case dto.NotifyTypeEmail:
|
||||
settings.NotificationEmail = req.NotificationEmail
|
||||
}
|
||||
|
||||
// 如果是Bark类型,添加Bark URL到设置中
|
||||
if req.QuotaWarningType == dto.NotifyTypeBark {
|
||||
case dto.NotifyTypeBark:
|
||||
settings.BarkUrl = req.BarkUrl
|
||||
}
|
||||
|
||||
// 如果是Gotify类型,添加Gotify配置到设置中
|
||||
if req.QuotaWarningType == dto.NotifyTypeGotify {
|
||||
case dto.NotifyTypeGotify:
|
||||
settings.GotifyUrl = req.GotifyUrl
|
||||
settings.GotifyToken = req.GotifyToken
|
||||
// Gotify优先级范围0-10,超出范围则使用默认值5
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/service/authz"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
@@ -641,3 +642,85 @@ func TestManageUserQuotaCacheUsesCommittedIntegerDifference(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Saving notification settings starts from what the user stored: fields the
|
||||
// form does not manage stay, and switches an older client leaves out keep
|
||||
// their stored values. Only administrators change the channel and upstream
|
||||
// switches, and a threshold of 0 means the site default.
|
||||
func TestUpdateUserSettingKeepsWhatTheRequestLeavesOut(t *testing.T) {
|
||||
on, off := true, false
|
||||
stored := dto.UserSetting{
|
||||
NotifyType: dto.NotifyTypeBark,
|
||||
BarkUrl: "https://api.day.app/key/{{title}}/{{content}}",
|
||||
QuotaWarningThreshold: 500000,
|
||||
QuotaWarningEnabled: &off,
|
||||
ChannelNotifyEnabled: &on,
|
||||
UpstreamModelUpdateNotifyEnabled: true,
|
||||
Language: "zhCN",
|
||||
SidebarModules: `{"chat":{"enabled":true}}`,
|
||||
BillingPreference: "wallet_only",
|
||||
}
|
||||
kept := func(setting dto.UserSetting) dto.UserSetting {
|
||||
setting.Language, setting.SidebarModules, setting.BillingPreference = stored.Language, stored.SidebarModules, stored.BillingPreference
|
||||
return setting
|
||||
}
|
||||
switchesChanged := `{"notify_type":"email","quota_warning_threshold":0,"quota_warning_enabled":true,"channel_notify_enabled":false,"upstream_model_update_notify_enabled":false}`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
role int
|
||||
body string
|
||||
wantSuccess bool
|
||||
want dto.UserSetting
|
||||
}{
|
||||
{
|
||||
name: "older client without the new switches",
|
||||
role: common.RoleAdminUser,
|
||||
body: `{"notify_type":"webhook","quota_warning_threshold":2000,"webhook_url":"https://example.com/hook","webhook_secret":"hook-secret","accept_unset_model_ratio_model":true,"record_ip_log":true}`,
|
||||
wantSuccess: true,
|
||||
want: kept(dto.UserSetting{
|
||||
NotifyType: dto.NotifyTypeWebhook, WebhookUrl: "https://example.com/hook", WebhookSecret: "hook-secret",
|
||||
QuotaWarningThreshold: 2000, QuotaWarningEnabled: &off, ChannelNotifyEnabled: &on, UpstreamModelUpdateNotifyEnabled: true,
|
||||
AcceptUnsetRatioModel: true, RecordIpLog: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "administrator changes the switches and uses the site default threshold",
|
||||
role: common.RoleAdminUser,
|
||||
body: switchesChanged,
|
||||
wantSuccess: true,
|
||||
want: kept(dto.UserSetting{NotifyType: dto.NotifyTypeEmail, QuotaWarningEnabled: &on, ChannelNotifyEnabled: &off}),
|
||||
},
|
||||
{
|
||||
name: "user cannot change the administrator switches",
|
||||
role: common.RoleCommonUser,
|
||||
body: switchesChanged,
|
||||
wantSuccess: true,
|
||||
want: kept(dto.UserSetting{NotifyType: dto.NotifyTypeEmail, QuotaWarningEnabled: &on, ChannelNotifyEnabled: &on, UpstreamModelUpdateNotifyEnabled: true}),
|
||||
},
|
||||
{
|
||||
name: "negative threshold",
|
||||
role: common.RoleCommonUser,
|
||||
body: `{"notify_type":"email","quota_warning_threshold":-1}`,
|
||||
want: stored,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := setupManageUserTestDB(t)
|
||||
setting, err := common.Marshal(stored)
|
||||
require.NoError(t, err)
|
||||
user := model.User{Username: "setting-owner", Role: tc.role, Status: common.UserStatusEnabled, AffCode: "setting-owner-aff", Setting: string(setting)}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/api/user/setting", strings.NewReader(tc.body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("id", user.Id)
|
||||
UpdateUserSetting(c)
|
||||
|
||||
assert.Contains(t, recorder.Body.String(), fmt.Sprintf(`"success":%t`, tc.wantSuccess))
|
||||
require.NoError(t, db.First(&user, user.Id).Error)
|
||||
assert.Equal(t, tc.want, user.GetSetting())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -157,7 +158,7 @@ func WeChatBind(c *gin.Context) {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
notificationFailed = service.NotifyAccountSecurityChange(user.Email, "WeChat account linked") != nil
|
||||
notificationFailed = service.NotifyAccountSecurityChange(service.AccountMailLanguage(c, user.Id), user.Email, i18n.MsgSecurityEventWeChatLinked) != nil
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
|
||||
Vendored
+3
-2
@@ -1,5 +1,5 @@
|
||||
export type JSONValue = null | boolean | number | string | readonly JSONValue[] | {readonly [key: string]: JSONValue};
|
||||
/** json-clone@1 and submit-sse-delta@1 are accepted for earlier hosts; new plugins need neither. */
|
||||
/** json-clone@1, submit-sse-delta@1 and duration-auto@1 are accepted for earlier hosts; new plugins need none of them. */
|
||||
export type HostCapability = "json-clone@1" | "submit-sse-delta@1" | "json-order@1" | "duration-auto@1";
|
||||
/** Kinds of upstream a driver can address: the vendor API itself, or another New API gateway with the same plugin installed. */
|
||||
export type UpstreamKind = "vendor" | "new_api";
|
||||
@@ -59,7 +59,7 @@ export type UsageExample = {label: string; facts: Readonly<Record<string, string
|
||||
export type UsageProfile = {models: readonly string[]; schema: Readonly<Record<string, UsageFieldSchema>>; examples?: readonly UsageExample[]};
|
||||
export interface Meta {requiredCapabilities?: readonly HostCapability[]; submitResponseTypes?: readonly ("json" | "sse")[]; sortPriority?: number; website?: string; apiVersion: 1; key: string; name: string; icon?: string; description?: LocalizedText; version: string; author: {name: string; url?: string}; baseUrl?: string; channelTypes?: readonly number[]; models: readonly string[]; fetchMode: "per_task" | "batch"; allowedHosts?: readonly string[]; upstreams?: readonly UpstreamKind[]; routes?: readonly NativeRoute[]; protocols?: readonly ProtocolClaim[]; usageSchema?: Readonly<Record<string, UsageFieldSchema>>; usageExamples?: readonly UsageExample[]; usageProfiles?: readonly UsageProfile[]; auth?: "none" | "api_key" | "vertex_oauth" | {type: "none" | "api_key" | "oauth2_jwt"}}
|
||||
export interface TaskView {task_id: string; status: string; progress?: string; fail_reason?: string; created_at?: number; updated_at?: number; data?: unknown; properties?: Record<string, unknown>}
|
||||
export interface DriverContext {requestBody: unknown; requestHeaders: Readonly<Record<string, string>>; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; upstream: UpstreamContext; files: readonly FileReference[]; publicTaskId: string; originTasks?: readonly {taskId: string; upstreamTaskId: string; action: string; status: string; data: unknown}[]}
|
||||
export interface DriverContext {requestBody: unknown; /** Client request headers, first value under the canonical name, without gateway credentials; Content-Type and Accept are always present. Older gateways pass only those two. */ requestHeaders: Readonly<Record<string, string>>; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; upstream: UpstreamContext; files: readonly FileReference[]; publicTaskId: string; originTasks?: readonly {taskId: string; upstreamTaskId: string; action: string; status: string; data: unknown}[]}
|
||||
export interface TaskQueryContext {taskId: string; publicTaskId: string; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; upstream: UpstreamContext; data: unknown; state: unknown}
|
||||
export interface BatchQueryContext {baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; upstream: UpstreamContext; tasks: readonly TaskQueryContext[]}
|
||||
export type HookHTTPResponse = {readonly status: number; readonly headers: Readonly<Record<string, string>>}
|
||||
@@ -86,6 +86,7 @@ export interface SubmitEventDeltaResult {changes: readonly JSONChange[]; state:
|
||||
export declare function parseSubmitEventDelta(ctx: DriverContext, event: SubmitEvent, previousState: JSONValue | null): SubmitEventDeltaResult;
|
||||
export declare function parseSubmitEvent(ctx: DriverContext, event: SubmitEvent, previousState: JSONValue | null): {state: JSONValue; done: boolean};
|
||||
export declare function parseSubmitResponse(ctx: DriverContext, response: UpstreamResponse): {taskId: string; taskData?: unknown; immediate?: NormalizedTaskResult; state?: unknown};
|
||||
/** Query redirects retain credentials only within the channel host and meta.allowedHosts; SSRF checks and HTTPS downgrade protection still apply. */
|
||||
export declare function buildQueryRequest(ctx: TaskQueryContext): RequestDescriptor;
|
||||
export declare function buildBatchQueryRequest(ctx: BatchQueryContext, tasks: readonly TaskQueryContext[]): RequestDescriptor;
|
||||
export declare function parseTaskResult(ctx: TaskQueryContext, body: unknown, response: HookHTTPResponse): NormalizedTaskResult;
|
||||
|
||||
+59
-20
@@ -42,7 +42,7 @@ A plugin whose vendor has no LobeHub icon ships its logo as a sidecar file, `ico
|
||||
|
||||
`baseUrl` is an optional default upstream address for channels of type 61 ("Task Plugin"). When an administrator binds the plugin and leaves the channel Base URL empty, the host copies this value onto the channel before validation and records `base_url_source: plugin_default` in the channel audit event; the persisted channel value is what every later request uses, so a later plugin update that changes `baseUrl` does not move existing channels. The value must be an absolute `http` or `https` URL without credentials, query, or fragment; the host is lowercased and must be ASCII (use punycode), trailing slashes are stripped, and the normalized value must not exceed 191 characters (the width of the channel `base_url` column on MySQL). Private, loopback, and plain `http` addresses are allowed for self-hosted upstreams; the admin UI flags them before binding. `baseUrl` is ignored on legacy `channelTypes` channels, which keep the built-in per-type default. The default host is not implicitly added to `allowedHosts`: if the administrator points the channel at a different host, plugin requests to the author's default host are rejected.
|
||||
|
||||
`allowedHosts` lists extra hosts that plugin requests may target besides the channel base URL host. Each entry is `host` or `host:port` (IPv6 literals bracketed, for example `[::1]:8080`); schemes, paths, credentials, and queries are rejected. Entries are lowercased and must be unique after normalization. An entry with a port matches only requests to that port (default ports `80`/`443` match requests that omit them).
|
||||
`allowedHosts` lists extra hosts that plugin requests may target besides the channel base URL host. These are trusted credential recipients for both direct requests and task query redirects; add only hosts permitted to receive the channel credentials. Each entry is `host` or `host:port` (IPv6 literals bracketed, for example `[::1]:8080`); schemes, paths, credentials, and queries are rejected. Entries are lowercased and must be unique after normalization. An entry with a port matches only requests to that port (default ports `80`/`443` match requests that omit them).
|
||||
|
||||
`upstreams` lists the kinds of upstream the driver's request builders can address. `vendor` is the provider API itself and is implied for every plugin; `new_api` is another New API gateway that has the same plugin installed. Only a plugin that declares `new_api` can be bound to a type-60 New API channel: the host rejects other bindings when the channel is saved, and the channel form lists only declaring plugins. A gateway does not serve vendor paths at their vendor location. It serves a plugin's wire format only on that plugin's own native routes, which carry the plugin's prefix because `/api` is a reserved host namespace and vendor paths would collide across plugins (`/doubao/api/v3/...`, `/ali/api/v1/...`, `/kling/v1/videos/...`), and it authenticates native routes and host protocols with `Authorization: Bearer <token>`. The host therefore tells the driver which kind of upstream the executing channel is through `ctx.upstream` (see Persisted data and driver hooks) instead of guessing a prefix from `meta.routes`; a plugin whose vendor paths already equal its native routes (sunoapi) or that only uses host protocols (sora) declares `new_api` without changing any URL. Values must be unique and drawn from the two kinds above; listing `vendor` is optional and descriptive. This is an additive apiVersion 1 extension: older hosts reject the unknown field, and a declaring plugin keeps building vendor URLs on hosts that never set `ctx.upstream`.
|
||||
|
||||
@@ -62,7 +62,16 @@ A plugin whose vendor has no LobeHub icon ships its logo as a sidecar file, `ico
|
||||
|
||||
`channelTypes` lists the legacy channel types this plugin's driver can drive (for example, sora declares `[55, 1]` because the same OpenAI-type base URL and bearer key serve both chat and video). Every entry equally participates in channel selection, historical `Task.Platform` matching, and the `byChannelType` routing index; the same type value may not appear on two plugins, and neither the Task Plugin (61) nor the New API (60) type may be declared. Third-party plugins normally omit `channelTypes` and live on type-61 "Task Plugin" channels bound by `task_plugin_key`, or are listed in a type-60 New API channel's `task_extend_plugin_keys` without claiming a channel type. The previous split identity/compatibility field names are rejected.
|
||||
|
||||
Before channel distribution, the host calls every mode-compatible candidate's
|
||||
On shared host endpoints, ordinary relay channels and matching plugin channels
|
||||
compete under the usual group, priority, weight, affinity, and channel-pin rules.
|
||||
A plugin's model declaration does not exclude an ordinary OpenAI-compatible
|
||||
channel serving the same model. When an ordinary channel wins, the host keeps
|
||||
the original request body and skips plugin mode checks, decoders, and model
|
||||
rewrites. Retries remain on ordinary channels for that endpoint; plugin retries
|
||||
remain on its accepted plugin candidates. Plugin-owned native routes keep their
|
||||
exclusive routing.
|
||||
|
||||
When a plugin channel wins initial selection, before final distribution the host calls every mode-compatible candidate's
|
||||
`decodeRequest`. Only candidates returning a valid submit intent for the pinned
|
||||
model remain eligible. If all reject the request, the host returns the candidate
|
||||
failures, grouping identical messages with their plugin keys. A single candidate
|
||||
@@ -82,6 +91,17 @@ labels continue to come from the first plugin in ascending key order. Saving a
|
||||
model expression checks every candidate without its own override in the same
|
||||
draft. Provider overrides are not included in model-price synchronization.
|
||||
|
||||
When the same model also uses ordinary relay channels, keep ordinary pricing at
|
||||
the model level and task pricing in plugin overrides. Ordinary relay expressions
|
||||
cannot call `u()`; use `fixed(...) * image_count` for per-image USD prices.
|
||||
Task expressions return USD directly, while ordinary expressions retain their
|
||||
USD-times-one-million result contract. An ordinary request fails with 400
|
||||
`model_price_error` before reservation or upstream submission if it encounters a
|
||||
usage expression, or an expression still inherited by an endpoint's task plugin.
|
||||
Expressions containing `fixed()` are unambiguously ordinary pricing, since task
|
||||
usage billing already rejects them. Existing task expressions are never rewritten
|
||||
or converted automatically.
|
||||
|
||||
Saved overrides remain visible and effective when only one provider is left.
|
||||
An override whose plugin is unavailable or no longer declares the model is
|
||||
marked `stale` in the administration snapshot. Its unchanged value can be kept
|
||||
@@ -122,6 +142,8 @@ Upgrade the host before installing a plugin that declares it.
|
||||
|
||||
`meta.usageExamples` is an optional display-only list of pricing examples. Each `label` is a human-readable spec name. Each `facts` object must be a complete vector over `usageSchema`: every declared key present, no undeclared keys, and values that pass the same validation as usage facts. Plugins whose schema contains a `unit: "token"` number field must declare at least one example. Examples never affect billing. Labels are spec names such as `std · 10s`, not storefront prices.
|
||||
|
||||
The host bounds every usage fact before it calculates quota and does not read billing quantities from the request. Numeric facts must be finite and non-negative, `second` facts at most 3600 and `count` facts at most 128; `token` and `credit` facts saturate at 2,147,483,647. Submission facts that break these rules fail the request with 400 before billing. The plugin validates its own request: it derives its facts from the request it sends upstream, including values it merges from `metadata` or other passthrough maps, and bounds any request quantity it turns into a `token` or `credit` fact, because the host only saturates those. A plugin that forwards a duration of `-1`, which some vendors read as "the model picks the length" (Seedance on Volcengine Ark, wan3.0 on Bailian), reserves the longest duration the model can produce and settles the task on the usage the vendor reports.
|
||||
|
||||
### Usage profiles by model
|
||||
|
||||
`meta.usageProfiles` optionally contains `{models, schema, examples?}` entries.
|
||||
@@ -233,10 +255,11 @@ supports optional feature detection on hosts that provide this utility; it
|
||||
does not grant a declared capability. These are additive API v1 extensions;
|
||||
older strict hosts reject `requiredCapabilities`, so upgrade the host first.
|
||||
|
||||
`json-clone@1` and `submit-sse-delta@1` remain accepted for plugins written for
|
||||
earlier hosts; new plugins need neither. `json-clone@1` has no effect.
|
||||
`submit-sse-delta@1` still requires `submitResponseTypes` to include `sse` and
|
||||
the plugin to export `parseSubmitEventDelta`.
|
||||
`json-clone@1`, `submit-sse-delta@1` and `duration-auto@1` remain accepted for
|
||||
plugins written for earlier hosts; new plugins need none of them.
|
||||
`json-clone@1` and `duration-auto@1` have no effect. `submit-sse-delta@1` still
|
||||
requires `submitResponseTypes` to include `sse` and the plugin to export
|
||||
`parseSubmitEventDelta`.
|
||||
|
||||
`utils.json.clone(value)` returns an independent JSON snapshot
|
||||
with native mutable JavaScript objects and arrays. It accepts plain objects,
|
||||
@@ -261,20 +284,6 @@ driver hook call parses the ordered body again, which costs more on large
|
||||
bodies, so declare the capability only when the upstream needs it. A body
|
||||
that embeds a file placeholder is still written sorted.
|
||||
|
||||
`duration-auto@1` lets a request carry a duration of `-1`, which some vendors
|
||||
read as "the model picks the length" (Seedance on Volcengine Ark, wan3.0 on
|
||||
Bailian). Before billing, the host checks every member of the decoded request
|
||||
at any depth, including vendor parameters passed through `metadata`: a
|
||||
`duration` or `seconds` member (case, `_` and `-` are ignored, so
|
||||
`durationSeconds` counts) must be between 0 and 3600. For a plugin that
|
||||
declares the capability the host also accepts exactly `-1` there; other
|
||||
negative values and values above the limit are still rejected. Usage facts
|
||||
are unchanged and must stay non-negative, so the plugin never reports `-1` as
|
||||
a quantity. A declaring plugin reserves the longest duration the model can
|
||||
produce when it forwards `-1`, and settles the task on the usage the vendor
|
||||
reports, so the charge follows the length the model chose. A plugin that
|
||||
cannot settle on reported usage must not declare it.
|
||||
|
||||
Host functions are synchronous and must bound their own work: the JavaScript
|
||||
execution interrupt cannot preempt a running Go function. Request-scoped data
|
||||
must remain owned by the host request, not by pooled runtime globals.
|
||||
@@ -392,10 +401,20 @@ Every decoder receives one host-parsed body:
|
||||
|
||||
A JSON `value` enumerates its object members sorted by key, or in the client's order for a plugin that declares `json-order@1`.
|
||||
|
||||
Native `submit` and `dynamic` routes accept JSON and multipart bodies; other media types and an empty body are rejected with 415 before the decoder runs, and `query` routes take no body. A route that declares `models` accepts JSON only, because the host reads the model from the JSON body. A decoder rejects the body kinds its vendor format does not use, typically with `JSON body required`. Gateways released before this extension accept only JSON on native routes.
|
||||
|
||||
`FileReference` contains `{ref, field, filename, mimeType, size}`. The ref is opaque and request-scoped; file bytes, readers, temporary paths, and a second inbound `requestBody` representation are never exposed to JavaScript. Every uploaded file has its own ref, including several files in one repeated field (`image[]`): the first file of a field is `request_file:<field>` and later files are `request_file:<field>#<index>` with a zero-based index. Plugins copy `files[].ref` instead of spelling refs by hand. The decoder creates a new canonical `{kind:"submit", model, action?, requestBody?, originTaskIds?}` intent. Driver hooks consume that normalized `requestBody` and remain surface-independent.
|
||||
|
||||
## Request building
|
||||
|
||||
Channel **Request Header Override** settings apply after descriptor headers on
|
||||
submission, query, batch query and credentialed content requests, and replace
|
||||
descriptor headers of the same name. `{api_key}` is the key the request
|
||||
authenticates with. Passthrough rules and `{client_header:NAME}` apply only to
|
||||
submission. Content requests keep the host of their URL; credentialless content
|
||||
requests and `utils.fetch` are sent as the plugin builds them. An invalid header
|
||||
value fails the request.
|
||||
|
||||
`buildSubmitRequest` returns a request descriptor. JSON bodies (the default) may embed a file placeholder at any object or array depth. The host replaces each placeholder with an encoded string before the request is sent; JavaScript never sees file bytes.
|
||||
|
||||
```js
|
||||
@@ -461,6 +480,8 @@ The persisted field remains `task.data`; there is no `task.raw` alias. Driver ho
|
||||
|
||||
`ctx.upstream` is `{kind: "vendor"}` or `{kind: "new_api"}` on `DriverContext`, `TaskQueryContext`, `BatchQueryContext`, and the `buildContentRequest` context, for submission and for background polling alike. `new_api` means the executing channel is a type-60 New API channel, so the same plugin on the upstream gateway answers. In that case the driver prefixes the URLs it builds with its own native-route prefix (doubao `/doubao`, alibaba `/ali`, kling `/kling`, jimeng `/jimeng`), and the host has already set `authHeader` to `Bearer <channel key>` and `apiKey` to the channel key for every `meta.auth` type: the channel key of a New API channel is the gateway token, so the vendor auth scheme (`oauth2_jwt` token exchange, a vendor JWT built from `ak|sk`) is not run. With `vendor` nothing changes. The response the driver reads back is what its own native presenter renders on the gateway, where the host has replaced the private upstream id in `id` / `task_id` / `taskId` fields with the gateway's public task id; `parseSubmitResponse` must accept that shape, and the query URL must resolve on the plugin's own `query` route with that id. Kling and jimeng keep their `sk-` key-prefix heuristic for legacy channels pointed at a gateway; the explicit signal takes precedence when it says `new_api`. `ctx.upstream` is an additive apiVersion 1 extension; drivers must treat a missing field as `vendor`.
|
||||
|
||||
`ctx.requestHeaders` on `DriverContext` holds the client's request headers, the first value of each under its canonical name (`TypeSafe-Danger-Zone-Long-Context` arrives as `Typesafe-Danger-Zone-Long-Context`), so drivers compare names case-insensitively. `Content-Type` and `Accept` are always present, empty when the client sent none. The headers that carry gateway credentials (`Authorization`, `Proxy-Authorization`, `Cookie`, `X-Api-Key`, `X-Goog-Api-Key`, `Mj-Api-Secret`) are left out. The host forwards none of them upstream; a driver copies only the headers its vendor defines into its request descriptor. Polling contexts have no client request and carry no headers. Gateways released before this extension pass only `Content-Type` and `Accept`.
|
||||
|
||||
## Polling contract
|
||||
|
||||
Query and parse hooks use `TaskQueryContext`, not `DriverContext`. The host rebuilds that context from the persisted task row. There is no query-side `requestBody`.
|
||||
@@ -483,6 +504,24 @@ Query and parse hooks use `TaskQueryContext`, not `DriverContext`. The host rebu
|
||||
|
||||
`buildBatchQueryRequest(ctx, tasks)` and `parseBatchResult` receive `tasks: TaskQueryContext[]`. `parseTaskResult` / `parseBatchResult` also receive `{status, headers}` for the upstream HTTP response.
|
||||
|
||||
Single and batch task query redirects use the same credential boundary as direct
|
||||
plugin requests: the channel base URL host and `meta.allowedHosts`, with the
|
||||
existing exact host and port matching. An omitted `allowedHosts` grants no extra
|
||||
hosts. Plugins that need another credential recipient add it to this existing
|
||||
list; no redirect option or query descriptor field is needed.
|
||||
|
||||
Redirects outside that boundary continue without the original headers except
|
||||
`Accept` and `Accept-Language`; `Referer` and URL userinfo are removed as well.
|
||||
After any untrusted hop, credentials stay removed even if a later hop returns
|
||||
to a trusted host. Arbitrary plugin and channel credential headers are covered.
|
||||
307/308 may replay a query body only along a trusted chain; other body replays
|
||||
fail that poll and use the existing poll failure handling.
|
||||
|
||||
Every redirect still passes the existing SSRF checks and the limit of 10
|
||||
requests in a redirect chain. HTTPS-to-HTTP redirects are rejected even to an
|
||||
allowed host. HTTPS upgrades within the trusted hosts are allowed.
|
||||
`utils.fetch` keeps returning redirects to the plugin.
|
||||
|
||||
`status: "UNKNOWN"` means the plugin does not recognize the response. Do not write `|| "IN_PROGRESS"` (or equivalent) for a missing table entry. The host treats `UNKNOWN`, hook errors, empty status, and unrecognized status strings as consecutive poll failures.
|
||||
|
||||
The host classifies the HTTP status before trusting a non-terminal parse:
|
||||
|
||||
@@ -37,6 +37,7 @@ type ChannelFilterKind string
|
||||
|
||||
const (
|
||||
FilterRequestPath ChannelFilterKind = "request_path"
|
||||
FilterTaskPluginEndpoint ChannelFilterKind = "task_plugin_endpoint"
|
||||
FilterTaskPluginIdentity ChannelFilterKind = "task_plugin_identity"
|
||||
FilterResponsesWebSocket ChannelFilterKind = "responses_websocket"
|
||||
)
|
||||
@@ -47,6 +48,9 @@ type ChannelFilter struct {
|
||||
TaskPluginKey string
|
||||
TaskPluginKeys []string
|
||||
TaskPluginChannelTypes []int
|
||||
// TaskPluginRelayOnly excludes endpoint candidates after an ordinary relay
|
||||
// channel wins selection, so retries keep using the same protocol handler.
|
||||
TaskPluginRelayOnly bool
|
||||
}
|
||||
|
||||
type ChannelConstraints struct {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module github.com/QuantumNous/new-api
|
||||
|
||||
// +heroku goVersion go1.18
|
||||
go 1.26
|
||||
// +heroku goVersion go1.26.10
|
||||
go 1.26.10
|
||||
|
||||
require (
|
||||
github.com/Calcium-Ion/go-epay v0.0.5
|
||||
@@ -32,7 +32,7 @@ require (
|
||||
github.com/jfreymuth/oggvorbis v1.0.5
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/klauspost/compress v1.18.3
|
||||
github.com/klauspost/compress v1.18.7
|
||||
github.com/mewkiz/flac v1.0.13
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
|
||||
@@ -1497,8 +1497,8 @@ github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e
|
||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
|
||||
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
|
||||
+23
-1
@@ -40,6 +40,8 @@ const (
|
||||
MsgQuotaSubscriptionInsufficient = "quota.subscription_insufficient"
|
||||
MsgRelayModelPriceNotConfigured = "relay.model_price_not_configured"
|
||||
MsgRelayModelPriceNotConfiguredAdmin = "relay.model_price_not_configured_admin"
|
||||
MsgRelayTaskUsagePriceUnsupported = "relay.task_usage_price_unsupported"
|
||||
MsgRelaySharedTaskPriceNeedsOverride = "relay.shared_task_price_needs_override"
|
||||
MsgRelayTaskChannelDisabled = "relay.task_channel_disabled"
|
||||
MsgRelayGroupSaturated = "relay.group_saturated"
|
||||
MsgRelayGroupUpstreamSaturated = "relay.group_upstream_saturated"
|
||||
@@ -63,6 +65,25 @@ const (
|
||||
MsgEmailPasswordResetLink = "email.password_reset.link"
|
||||
MsgEmailPasswordResetLinkFallback = "email.password_reset.link_fallback"
|
||||
MsgEmailPasswordResetValidity = "email.password_reset.validity"
|
||||
|
||||
MsgEmailBindingSubject = "email.binding.subject"
|
||||
MsgEmailBindingNewIntro = "email.binding.new_intro"
|
||||
MsgEmailBindingOldIntro = "email.binding.old_intro"
|
||||
MsgEmailBindingCode = "email.binding.code"
|
||||
MsgEmailBindingNewValidity = "email.binding.new_validity"
|
||||
MsgEmailBindingOldValidity = "email.binding.old_validity"
|
||||
|
||||
MsgEmailSecurityNoticeSubject = "email.security_notice.subject"
|
||||
MsgEmailSecurityNoticeChanged = "email.security_notice.changed"
|
||||
MsgEmailSecurityNoticeAdvice = "email.security_notice.advice"
|
||||
|
||||
MsgSecurityEventPasswordUpdated = "email.security_event.password_updated"
|
||||
MsgSecurityEventEmailChangeRequested = "email.security_event.email_change_requested"
|
||||
MsgSecurityEventEmailChanged = "email.security_event.email_changed"
|
||||
MsgSecurityEventEmailConfirmed = "email.security_event.email_confirmed"
|
||||
MsgSecurityEventLoginLinked = "email.security_event.login_linked"
|
||||
MsgSecurityEventLoginUnlinked = "email.security_event.login_unlinked"
|
||||
MsgSecurityEventWeChatLinked = "email.security_event.wechat_linked"
|
||||
)
|
||||
|
||||
// Low-quota notices, in the recipient's saved language
|
||||
@@ -74,7 +95,8 @@ const (
|
||||
MsgNotifyQuotaLowEmail = "notify.quota_low_email"
|
||||
)
|
||||
|
||||
// Channel notices sent to the root user and model update watchers
|
||||
// Channel notices sent to administrators who receive them and to model update
|
||||
// watchers
|
||||
const (
|
||||
MsgChannelNotifyDisabledSubject = "channel.notify_disabled_subject"
|
||||
MsgChannelNotifyDisabledContent = "channel.notify_disabled_content"
|
||||
|
||||
+19
-1
@@ -31,6 +31,8 @@ quota.pre_consume_insufficient: "Not enough quota to pre-consume for this reques
|
||||
quota.subscription_insufficient: "Subscription quota is insufficient or no subscription is configured: {{.Error}}"
|
||||
relay.model_price_not_configured: "Model {{.Model}} has not been priced by the administrator yet. Please contact the site administrator to enable this model."
|
||||
relay.model_price_not_configured_admin: "Model {{.Model}} price not configured. Go to System Settings → Operation Settings to enable self-use mode, or configure the model price in System Settings → Group & Model Pricing."
|
||||
relay.task_usage_price_unsupported: "Model {{.Model}} uses a task usage expression; configure ordinary relay pricing and separate plugin billing expressions"
|
||||
relay.shared_task_price_needs_override: "Model {{.Model}} shares its billing expression with task plugin {{.Plugin}}; configure a separate plugin billing expression before using ordinary relay pricing"
|
||||
relay.task_channel_disabled: "The channel of this task has been disabled"
|
||||
relay.group_saturated: "The current group is at capacity. Please try again later, or upgrade your account for better service."
|
||||
relay.group_upstream_saturated: "The upstream of the current group is at capacity. Please try again later"
|
||||
@@ -49,6 +51,22 @@ email.password_reset.greeting: "Hello, you are resetting your password for {{.Sy
|
||||
email.password_reset.link: "Click <a href='{{.Link}}'>here</a> to reset your password."
|
||||
email.password_reset.link_fallback: "If the link does not open, copy the link below into your browser:<br> {{.Link}}"
|
||||
email.password_reset.validity: "The reset link is valid for {{.Minutes}} minutes. If you did not request it, please ignore this email."
|
||||
email.binding.subject: "{{.SystemName}} — Confirm your email address"
|
||||
email.binding.new_intro: "Confirm linking this email address to your account."
|
||||
email.binding.old_intro: "A change to your account email address was requested. Confirm replacing your current address."
|
||||
email.binding.code: "Verification code: <strong>{{.Code}}</strong>"
|
||||
email.binding.new_validity: "This code expires in {{.Minutes}} minutes. If you did not request this change, do not share this code."
|
||||
email.binding.old_validity: "This code expires in {{.Minutes}} minutes. If you did not request this change, do not share this code and contact your administrator."
|
||||
email.security_notice.subject: "{{.SystemName}} — Account security notification"
|
||||
email.security_notice.changed: "Your account security settings have changed: {{.Event}}."
|
||||
email.security_notice.advice: "If you did not make this change, open your account security settings, revoke other login sessions, and contact your administrator."
|
||||
email.security_event.password_updated: "Password updated"
|
||||
email.security_event.email_change_requested: "A change of your email address was requested"
|
||||
email.security_event.email_changed: "Email address changed"
|
||||
email.security_event.email_confirmed: "Email address confirmed"
|
||||
email.security_event.login_linked: "Login account linked: {{.Provider}}"
|
||||
email.security_event.login_unlinked: "Login account unlinked"
|
||||
email.security_event.wechat_linked: "WeChat account linked"
|
||||
|
||||
# Low-quota notices
|
||||
notify.quota_low_title: "Your quota is running low"
|
||||
@@ -57,7 +75,7 @@ notify.quota_low_bark: "{{.Prompt}}, remaining quota: {{.Quota}}. Please top up
|
||||
notify.quota_low_gotify: "{{.Prompt}}. Your remaining quota is {{.Quota}}. Please top up soon."
|
||||
notify.quota_low_email: "{{.Prompt}}. Your remaining quota is {{.Quota}}. Top up soon to keep using the service.<br/>Top-up link: <a href='{{.Link}}'>{{.Link}}</a>"
|
||||
|
||||
# Channel notices sent to the root user and model update watchers
|
||||
# Channel notices sent to administrators who receive them and to model update watchers
|
||||
channel.notify_disabled_subject: "Channel \"{{.Name}}\" (#{{.Id}}) has been disabled"
|
||||
channel.notify_disabled_content: "Channel \"{{.Name}}\" (#{{.Id}}) has been disabled. Reason: {{.Reason}}"
|
||||
channel.notify_enabled_subject: "Channel \"{{.Name}}\" (#{{.Id}}) has been enabled"
|
||||
|
||||
+19
-1
@@ -32,6 +32,8 @@ quota.pre_consume_insufficient: "预扣费额度失败, 用户剩余额度: {{.R
|
||||
quota.subscription_insufficient: "订阅额度不足或未配置订阅: {{.Error}}"
|
||||
relay.model_price_not_configured: "模型 {{.Model}} 的价格尚未由管理员配置,暂时无法使用,请联系站点管理员开启该模型;Model {{.Model}} has not been priced by the administrator yet. Please contact the site administrator to enable this model."
|
||||
relay.model_price_not_configured_admin: "模型 {{.Model}} 的价格未配置。请前往「系统设置 → 运营设置」开启自用模式,或在「系统设置 → 分组与模型定价设置」中为该模型配置价格;Model {{.Model}} price not configured. Go to System Settings → Operation Settings to enable self-use mode, or configure the model price in System Settings → Group & Model Pricing."
|
||||
relay.task_usage_price_unsupported: "模型 {{.Model}} 使用了任务用量表达式;请为普通转发配置常规定价,并为插件单独配置计费表达式"
|
||||
relay.shared_task_price_needs_override: "模型 {{.Model}} 与任务插件 {{.Plugin}} 共用了计费表达式;请先为插件配置独立的计费表达式,再使用普通转发定价"
|
||||
relay.task_channel_disabled: "该任务所属渠道已被禁用"
|
||||
relay.group_saturated: "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
|
||||
relay.group_upstream_saturated: "当前分组上游负载已饱和,请稍后再试"
|
||||
@@ -50,6 +52,22 @@ email.password_reset.greeting: "您好,你正在进行{{.SystemName}}密码重
|
||||
email.password_reset.link: "点击 <a href='{{.Link}}'>此处</a> 进行密码重置。"
|
||||
email.password_reset.link_fallback: "如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> {{.Link}}"
|
||||
email.password_reset.validity: "重置链接 {{.Minutes}} 分钟内有效,如果不是本人操作,请忽略。"
|
||||
email.binding.subject: "{{.SystemName}}邮箱绑定确认"
|
||||
email.binding.new_intro: "请确认将此邮箱地址绑定到您的账户。"
|
||||
email.binding.old_intro: "您的账户正在申请更换邮箱地址,请确认替换当前邮箱。"
|
||||
email.binding.code: "验证码:<strong>{{.Code}}</strong>"
|
||||
email.binding.new_validity: "验证码 {{.Minutes}} 分钟内有效。如果不是您本人操作,请勿把验证码告诉任何人。"
|
||||
email.binding.old_validity: "验证码 {{.Minutes}} 分钟内有效。如果不是您本人操作,请勿把验证码告诉任何人,并联系管理员。"
|
||||
email.security_notice.subject: "{{.SystemName}}账户安全提醒"
|
||||
email.security_notice.changed: "您的账户安全设置有变动:{{.Event}}。"
|
||||
email.security_notice.advice: "如果不是您本人操作,请打开账户安全设置,退出其他会话,并联系管理员。"
|
||||
email.security_event.password_updated: "密码已修改"
|
||||
email.security_event.email_change_requested: "已申请更换邮箱地址"
|
||||
email.security_event.email_changed: "邮箱地址已更换"
|
||||
email.security_event.email_confirmed: "邮箱地址已确认"
|
||||
email.security_event.login_linked: "已绑定登录账号 {{.Provider}}"
|
||||
email.security_event.login_unlinked: "已解绑登录账号"
|
||||
email.security_event.wechat_linked: "已绑定微信账号"
|
||||
|
||||
# Low-quota notices
|
||||
notify.quota_low_title: "您的额度即将用尽"
|
||||
@@ -58,7 +76,7 @@ notify.quota_low_bark: "{{.Prompt}},剩余额度:{{.Quota}},请及时充
|
||||
notify.quota_low_gotify: "{{.Prompt}},当前剩余额度为 {{.Quota}},请及时充值。"
|
||||
notify.quota_low_email: "{{.Prompt}},当前剩余额度为 {{.Quota}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{.Link}}'>{{.Link}}</a>"
|
||||
|
||||
# Channel notices sent to the root user and model update watchers
|
||||
# Channel notices sent to administrators who receive them and to model update watchers
|
||||
channel.notify_disabled_subject: "通道「{{.Name}}」(#{{.Id}})已被禁用"
|
||||
channel.notify_disabled_content: "通道「{{.Name}}」(#{{.Id}})已被禁用,原因:{{.Reason}}"
|
||||
channel.notify_enabled_subject: "通道「{{.Name}}」(#{{.Id}})已被启用"
|
||||
|
||||
+19
-1
@@ -32,6 +32,8 @@ quota.pre_consume_insufficient: "預扣費額度失敗,使用者剩餘額度
|
||||
quota.subscription_insufficient: "訂閱額度不足或未設定訂閱:{{.Error}}"
|
||||
relay.model_price_not_configured: "模型 {{.Model}} 的價格尚未由管理員設定,暫時無法使用,請聯絡站點管理員開啟該模型"
|
||||
relay.model_price_not_configured_admin: "模型 {{.Model}} 的價格未設定。請前往「系統設定 → 營運設定」開啟自用模式,或在「系統設定 → 分組與模型定價設定」中為該模型設定價格"
|
||||
relay.task_usage_price_unsupported: "模型 {{.Model}} 使用了任務用量表達式;請為一般轉發設定常規定價,並為插件單獨設定計費表達式"
|
||||
relay.shared_task_price_needs_override: "模型 {{.Model}} 與任務插件 {{.Plugin}} 共用了計費表達式;請先為插件設定獨立的計費表達式,再使用一般轉發定價"
|
||||
relay.task_channel_disabled: "該任務所屬管道已被停用"
|
||||
relay.group_saturated: "目前分組負載已飽和,請稍後再試,或升級帳戶以提升服務品質。"
|
||||
relay.group_upstream_saturated: "目前分組上游負載已飽和,請稍後再試"
|
||||
@@ -50,6 +52,22 @@ email.password_reset.greeting: "您好,您正在進行{{.SystemName}}密碼重
|
||||
email.password_reset.link: "點擊 <a href='{{.Link}}'>此處</a> 進行密碼重設。"
|
||||
email.password_reset.link_fallback: "如果連結無法點擊,請嘗試點擊下面的連結或將其複製到瀏覽器中開啟:<br> {{.Link}}"
|
||||
email.password_reset.validity: "重設連結 {{.Minutes}} 分鐘內有效,如果不是本人操作,請忽略。"
|
||||
email.binding.subject: "{{.SystemName}}電子郵件綁定確認"
|
||||
email.binding.new_intro: "請確認將此電子郵件地址綁定到您的帳戶。"
|
||||
email.binding.old_intro: "您的帳戶正在申請更換電子郵件地址,請確認替換目前的電子郵件。"
|
||||
email.binding.code: "驗證碼:<strong>{{.Code}}</strong>"
|
||||
email.binding.new_validity: "驗證碼 {{.Minutes}} 分鐘內有效。如果不是您本人操作,請勿將驗證碼告訴任何人。"
|
||||
email.binding.old_validity: "驗證碼 {{.Minutes}} 分鐘內有效。如果不是您本人操作,請勿將驗證碼告訴任何人,並聯絡管理員。"
|
||||
email.security_notice.subject: "{{.SystemName}}帳戶安全提醒"
|
||||
email.security_notice.changed: "您的帳戶安全設定有變動:{{.Event}}。"
|
||||
email.security_notice.advice: "如果不是您本人操作,請開啟帳戶安全設定,登出其他工作階段,並聯絡管理員。"
|
||||
email.security_event.password_updated: "密碼已修改"
|
||||
email.security_event.email_change_requested: "已申請更換電子郵件地址"
|
||||
email.security_event.email_changed: "電子郵件地址已更換"
|
||||
email.security_event.email_confirmed: "電子郵件地址已確認"
|
||||
email.security_event.login_linked: "已綁定登入帳號 {{.Provider}}"
|
||||
email.security_event.login_unlinked: "已解綁登入帳號"
|
||||
email.security_event.wechat_linked: "已綁定微信帳號"
|
||||
|
||||
# Low-quota notices
|
||||
notify.quota_low_title: "您的額度即將用盡"
|
||||
@@ -58,7 +76,7 @@ notify.quota_low_bark: "{{.Prompt}},剩餘額度:{{.Quota}},請及時儲
|
||||
notify.quota_low_gotify: "{{.Prompt}},目前剩餘額度為 {{.Quota}},請及時儲值。"
|
||||
notify.quota_low_email: "{{.Prompt}},目前剩餘額度為 {{.Quota}},為了不影響您的使用,請及時儲值。<br/>儲值連結:<a href='{{.Link}}'>{{.Link}}</a>"
|
||||
|
||||
# Channel notices sent to the root user and model update watchers
|
||||
# Channel notices sent to administrators who receive them and to model update watchers
|
||||
channel.notify_disabled_subject: "管道「{{.Name}}」(#{{.Id}})已被停用"
|
||||
channel.notify_disabled_content: "管道「{{.Name}}」(#{{.Id}})已被停用,原因:{{.Reason}}"
|
||||
channel.notify_enabled_subject: "管道「{{.Name}}」(#{{.Id}})已被啟用"
|
||||
|
||||
@@ -100,13 +100,22 @@ func Distribute() func(c *gin.Context) {
|
||||
if pinned || shouldSelectChannel {
|
||||
usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
|
||||
var selectErr *service.ChannelSelectError
|
||||
channel, _, selectErr = service.SelectChannelForRequest(c, modelRequest.Model, &service.RetryParam{
|
||||
Ctx: c,
|
||||
ModelName: modelRequest.Model,
|
||||
TokenGroup: usingGroup,
|
||||
RequestPath: c.Request.URL.Path,
|
||||
Retry: common.GetPointer(0),
|
||||
})
|
||||
if value, exists := c.Get(contextKeyTaskPluginEndpointSelection); exists && !pinned {
|
||||
if selected, ok := value.(taskPluginEndpointSelection); ok && selected.Model == modelRequest.Model && selected.Channel != nil && selected.Channel.Status == common.ChannelStatusEnabled {
|
||||
if matches, _ := model.ChannelSatisfiesFilters(selected.Channel, modelRequest.Model, constraints.Filters); matches {
|
||||
channel = selected.Channel
|
||||
}
|
||||
}
|
||||
}
|
||||
if channel == nil {
|
||||
channel, _, selectErr = service.SelectChannelForRequest(c, modelRequest.Model, &service.RetryParam{
|
||||
Ctx: c,
|
||||
ModelName: modelRequest.Model,
|
||||
TokenGroup: usingGroup,
|
||||
RequestPath: c.Request.URL.Path,
|
||||
Retry: common.GetPointer(0),
|
||||
})
|
||||
}
|
||||
if selectErr != nil {
|
||||
if selectErr.FilterKind == taskdto.FilterTaskPluginIdentity {
|
||||
logTaskPluginChannelDecision(c, selectErr.Channel, modelRequest.Model, "channel_rejected", "identity_mismatch")
|
||||
@@ -458,6 +467,13 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
modelRequest.Model = c.Param("model")
|
||||
}
|
||||
}
|
||||
// The routing name is also the billing name, so a model named in the body
|
||||
// must not replace it: DeepSeek ignores that field.
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/v1/tools/search") {
|
||||
modelRequest.Model = constant.DeepSeekSearchModel
|
||||
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/tools/fetch") {
|
||||
modelRequest.Model = constant.DeepSeekFetchModel
|
||||
}
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") {
|
||||
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e")
|
||||
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") {
|
||||
|
||||
@@ -168,6 +168,27 @@ func TestTokenModelLimitAllowsLegacyAliasAndModifierVariant(t *testing.T) {
|
||||
assert.True(t, TokenModelLimitAllows(wildcard, "gemini-2.5-flash-thinking-8192"))
|
||||
}
|
||||
|
||||
// /v1/tools/search and /v1/tools/fetch always route and bill by the DeepSeek
|
||||
// routing names. A model named in the body is ignored: a chat model there
|
||||
// would price the call at zero tokens.
|
||||
func TestGetModelRequestAssignsDeepSeekToolRoutingNames(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, tc := range []struct{ path, body, want string }{
|
||||
{"/v1/tools/search", `{"query":"deepseek","count":3}`, constant.DeepSeekSearchModel},
|
||||
{"/v1/tools/fetch", `{"url":"https://example.com/"}`, constant.DeepSeekFetchModel},
|
||||
{"/v1/tools/search", `{"query":"deepseek","model":"deepseek-flash"}`, constant.DeepSeekSearchModel},
|
||||
{"/v1/tools/fetch", `{"url":"https://example.com/","model":"deepseek-v4-pro"}`, constant.DeepSeekFetchModel},
|
||||
} {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
modelRequest, shouldSelectChannel, err := getModelRequest(c)
|
||||
require.NoError(t, err, tc.body)
|
||||
assert.True(t, shouldSelectChannel, tc.body)
|
||||
assert.Equal(t, tc.want, modelRequest.Model, tc.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenModelLimitAllowsExemptAtNameByFullName(t *testing.T) {
|
||||
settings := model_setting.GetGlobalSettings()
|
||||
original := append([]string(nil), settings.ThinkingModelBlacklist...)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -33,6 +34,9 @@ func SetUpLogger(server *gin.Engine) {
|
||||
// Redact the log value only; the handler still needs the original query.
|
||||
if strings.HasPrefix(path, "/api/oauth/") || strings.HasPrefix(path, "/oauth/") {
|
||||
path, _, _ = strings.Cut(path, "?")
|
||||
} else if param.Request.URL.RawQuery != "" {
|
||||
query := param.Request.URL.Query().Encode()
|
||||
path = param.Request.URL.EscapedPath() + relaycommon.SanitizeURLForLog("?"+query)
|
||||
}
|
||||
return fmt.Sprintf("[GIN] %s | %s | %s | %3d | %13v | %15s | %7s %s\n",
|
||||
param.TimeStamp.Format("2006/01/02 - 15:04:05"),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSetUpLoggerMasksQueryCredentials(t *testing.T) {
|
||||
previousMode, previousWriter := gin.Mode(), gin.DefaultWriter
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() {
|
||||
gin.SetMode(previousMode)
|
||||
gin.DefaultWriter = previousWriter
|
||||
})
|
||||
|
||||
for _, tc := range []struct{ target, loggedPath string }{
|
||||
{"/v1/models?%6bey=first&key=second&API-Key=third&session_token=fourth&alt=sse",
|
||||
"/v1/models?API-Key=%2A%2A%2Amasked%2A%2A%2A&alt=sse&key=%2A%2A%2Amasked%2A%2A%2A&session_token=%2A%2A%2Amasked%2A%2A%2A"},
|
||||
{"/v1/models%23test%25?key=#secret&alt=#sse",
|
||||
"/v1/models%23test%25?alt=%23sse&key=%2A%2A%2Amasked%2A%2A%2A"},
|
||||
{"//v1%2Fmodels?key=secret", "//v1%2Fmodels?key=%2A%2A%2Amasked%2A%2A%2A"},
|
||||
{"/v1/models?key=bad%ZZ&%ZZ=bad&token=bad;other=true&alt=sse", "/v1/models?alt=sse"},
|
||||
{"/v1/models?key=bad%ZZ", "/v1/models?"},
|
||||
{"/v1/models?alt=sse&api-version=2024-02-01", "/v1/models?alt=sse&api-version=2024-02-01"},
|
||||
{"/v1/models", "/v1/models"},
|
||||
{"/%61pi/oauth/test?code=secret&state=secret", "/api/oauth/test"},
|
||||
} {
|
||||
t.Run(tc.target, func(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
gin.DefaultWriter = &output
|
||||
router := gin.New()
|
||||
SetUpLogger(router)
|
||||
request := httptest.NewRequest(http.MethodGet, tc.target, nil)
|
||||
originalURL := *request.URL
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
assert.Equal(t, originalURL, *c.Request.URL)
|
||||
assert.Equal(t, tc.target, c.Request.RequestURI)
|
||||
c.Status(http.StatusUnauthorized)
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, response.Code)
|
||||
_, loggedPath, found := strings.Cut(strings.TrimSuffix(output.String(), "\n"), "GET ")
|
||||
require.True(t, found)
|
||||
assert.Equal(t, tc.loggedPath, loggedPath)
|
||||
assert.Equal(t, originalURL, *request.URL)
|
||||
assert.Equal(t, tc.target, request.RequestURI)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
@@ -39,33 +39,11 @@ func SystemPerformanceCheck() gin.HandlerFunc {
|
||||
|
||||
// checkSystemPerformance 检查系统性能是否超过阈值
|
||||
func checkSystemPerformance() *types.NewAPIError {
|
||||
config := common.GetPerformanceMonitorConfig()
|
||||
if !config.Enabled {
|
||||
// 过载标记在采样侧完成:CPU / 内存按容器 cgroup 配额计算,并要求连续多次
|
||||
// 超阈值;判定时再按当前阈值复核,并忽略过期采样。
|
||||
overload := common.CheckSystemOverload(common.GetSystemStatus(), common.GetPerformanceMonitorConfig(), time.Now())
|
||||
if overload == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
status := common.GetSystemStatus()
|
||||
|
||||
// 检查 CPU
|
||||
if config.CPUThreshold > 0 && int(status.CPUUsage) > config.CPUThreshold {
|
||||
return types.NewErrorWithStatusCode(
|
||||
fmt.Errorf("system cpu overloaded (current: %.1f%%, threshold: %d%%)", status.CPUUsage, config.CPUThreshold),
|
||||
"system_cpu_overloaded", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
// 检查内存
|
||||
if config.MemoryThreshold > 0 && int(status.MemoryUsage) > config.MemoryThreshold {
|
||||
return types.NewErrorWithStatusCode(
|
||||
fmt.Errorf("system memory overloaded (current: %.1f%%, threshold: %d%%)", status.MemoryUsage, config.MemoryThreshold),
|
||||
"system_memory_overloaded", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
// 检查磁盘
|
||||
if config.DiskThreshold > 0 && int(status.DiskUsage) > config.DiskThreshold {
|
||||
return types.NewErrorWithStatusCode(
|
||||
fmt.Errorf("system disk overloaded (current: %.1f%%, threshold: %d%%)", status.DiskUsage, config.DiskThreshold),
|
||||
"system_disk_overloaded", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
return nil
|
||||
return types.NewErrorWithStatusCode(overload, types.ErrorCode(overload.Code), http.StatusServiceUnavailable)
|
||||
}
|
||||
+119
-57
@@ -33,17 +33,27 @@ import (
|
||||
|
||||
const contextKeyTaskPluginEndpointModel = "task_plugin_endpoint_model_request"
|
||||
|
||||
const contextKeyTaskPluginEndpointSelection = "task_plugin_endpoint_channel_selection"
|
||||
|
||||
type taskPluginEndpointSelection struct {
|
||||
Generation *pluginruntime.RoutingGeneration
|
||||
Channel *model.Channel
|
||||
Model string
|
||||
}
|
||||
|
||||
var errTaskPluginUnsupportedMediaType = errors.New("unsupported task plugin media type")
|
||||
|
||||
const taskPluginInvalidRouteResult = "plugin returned an invalid route result"
|
||||
|
||||
// Text fields have no limit of their own: like a JSON body, they are bounded
|
||||
// by the request body limit, so a JSON request sent as a multipart field may
|
||||
// be as large as the same request sent as the body.
|
||||
const (
|
||||
maxTaskPluginFormFields = 256
|
||||
maxTaskPluginMultipartParts = 256
|
||||
maxTaskPluginFiles = 32
|
||||
maxTaskPluginFieldNameBytes = 256
|
||||
maxTaskPluginFieldValueBytes = 1 << 20
|
||||
maxTaskPluginFilenameBytes = 255
|
||||
maxTaskPluginFormFields = 256
|
||||
maxTaskPluginMultipartParts = 256
|
||||
maxTaskPluginFiles = 32
|
||||
maxTaskPluginFieldNameBytes = 256
|
||||
maxTaskPluginFilenameBytes = 255
|
||||
)
|
||||
|
||||
// PrepareTaskPluginRoute resolves and executes the pinned declarative route.
|
||||
@@ -93,7 +103,11 @@ func PrepareTaskPluginRoute() gin.HandlerFunc {
|
||||
}
|
||||
bodyObject, _ := requestContext.Body.(map[string]any)
|
||||
bodyKind, _ := bodyObject["kind"].(string)
|
||||
if pinned.Route.Type == pluginruntime.RouteTypeQuery && bodyKind != string(pluginruntime.BodyNone) || pinned.Route.Type != pluginruntime.RouteTypeQuery && bodyKind != string(pluginruntime.BodyJSON) {
|
||||
// Submit routes hand JSON and multipart bodies to the decoder, which
|
||||
// rejects the kinds its vendor format does not use. A route scoped to
|
||||
// models reads the model from a JSON body, so it takes JSON only.
|
||||
submitBody := bodyKind == string(pluginruntime.BodyJSON) || bodyKind == string(pluginruntime.BodyMultipart) && len(pinned.Route.Models) == 0
|
||||
if pinned.Route.Type == pluginruntime.RouteTypeQuery && bodyKind != string(pluginruntime.BodyNone) || pinned.Route.Type != pluginruntime.RouteTypeQuery && !submitBody {
|
||||
logger.LogWarn(
|
||||
c,
|
||||
common.LogText("task_plugin subsystem=route event=prepare_rejected generation=%d plugin=%q stage=request_decode reason=body_kind_mismatch body_kind=%q",
|
||||
@@ -101,7 +115,10 @@ func PrepareTaskPluginRoute() gin.HandlerFunc {
|
||||
pinned.Plugin.Meta.Key,
|
||||
bodyKind),
|
||||
)
|
||||
detail := "this route requires a JSON body"
|
||||
detail := "this route requires a JSON or multipart body"
|
||||
if len(pinned.Route.Models) > 0 {
|
||||
detail = "this route requires a JSON body"
|
||||
}
|
||||
if pinned.Route.Type == pluginruntime.RouteTypeQuery {
|
||||
detail = "unsupported request body for this operation"
|
||||
}
|
||||
@@ -321,12 +338,93 @@ func PrepareTaskPluginRoute() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RouteTaskPluginEndpoint selects between ordinary relay and plugin channels
|
||||
// before a plugin can validate or rewrite the client's request. Plugin calls
|
||||
// still prepare their candidates and origin-task pins before final distribution.
|
||||
func RouteTaskPluginEndpoint() gin.HandlerFunc {
|
||||
pinEndpoint := PinTaskPluginEndpoint()
|
||||
return func(c *gin.Context) {
|
||||
generation := pluginruntime.DefaultRegistry.Generation()
|
||||
request, err := getModelFromRequest(c)
|
||||
if err != nil || generation == nil {
|
||||
pinEndpoint(c)
|
||||
return
|
||||
}
|
||||
endpoint, found := lookupTaskPluginEndpoint(generation, c.Request.Method, c.Request.URL.Path, request.Model)
|
||||
if !found {
|
||||
c.Set(contextKeyTaskPluginEndpointModel, *request)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
filter := dto.ChannelFilter{Kind: dto.FilterTaskPluginEndpoint}
|
||||
for _, candidate := range endpoint.Candidates {
|
||||
filter.TaskPluginKeys = append(filter.TaskPluginKeys, candidate.Plugin.Meta.Key)
|
||||
filter.TaskPluginChannelTypes = append(filter.TaskPluginChannelTypes, candidate.Plugin.Meta.ChannelTypes...)
|
||||
}
|
||||
constraints := service.GetChannelConstraints(c)
|
||||
filterIndex := len(constraints.Filters)
|
||||
constraints.AddFilter(filter)
|
||||
constraints.AddFilter(dto.ChannelFilter{Kind: dto.FilterRequestPath, RequestPath: c.Request.URL.Path})
|
||||
group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
|
||||
channel, _, selectErr := service.SelectChannelForRequest(c, request.Model, &service.RetryParam{
|
||||
Ctx: c, ModelName: request.Model, TokenGroup: group,
|
||||
RequestPath: c.Request.URL.Path, Retry: common.GetPointer(0),
|
||||
})
|
||||
c.Set(contextKeyTaskPluginEndpointSelection, taskPluginEndpointSelection{
|
||||
Generation: generation, Channel: channel, Model: request.Model,
|
||||
})
|
||||
if selectErr != nil {
|
||||
// Keep the existing plugin diagnostics and canonical-model lookup
|
||||
// when selection cannot resolve the client's original spelling.
|
||||
pinEndpoint(c)
|
||||
return
|
||||
}
|
||||
filter.Kind = dto.FilterTaskPluginIdentity
|
||||
filter.TaskPluginKey = endpoint.Plugin.Meta.Key
|
||||
if pluginChannel, _ := model.ChannelSatisfiesFilters(channel, request.Model, []dto.ChannelFilter{filter}); !pluginChannel {
|
||||
constraints.Filters[filterIndex].TaskPluginRelayOnly = true
|
||||
c.Set(pluginruntime.ContextKeyRelayEndpoint, endpoint)
|
||||
c.Set(contextKeyTaskPluginEndpointModel, *request)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
pinEndpoint(c)
|
||||
}
|
||||
}
|
||||
|
||||
func lookupTaskPluginEndpoint(generation *pluginruntime.RoutingGeneration, method, path, modelName string) (pluginruntime.PinnedEndpoint, bool) {
|
||||
lookupModel, pinModel, mappedModel := modelName, modelName, ""
|
||||
if declared, ok := generation.CanonicalModel(modelName); ok {
|
||||
lookupModel, pinModel = declared, declared
|
||||
} else if target, ok := model.ResolveTaskModelAlias(generation, modelName); ok {
|
||||
if target.Declared == "" {
|
||||
return pluginruntime.PinnedEndpoint{}, false
|
||||
}
|
||||
lookupModel, pinModel, mappedModel = target.Declared, target.Alias, target.Declared
|
||||
}
|
||||
candidates := generation.LookupEndpointCandidates(method, path, lookupModel)
|
||||
if len(candidates) == 0 {
|
||||
return pluginruntime.PinnedEndpoint{}, false
|
||||
}
|
||||
binding := candidates[0]
|
||||
return pluginruntime.PinnedEndpoint{
|
||||
Generation: generation, Plugin: binding.Plugin, Protocol: binding.Protocol,
|
||||
Operation: binding.Operation, Model: pinModel, MappedModel: mappedModel, Candidates: candidates,
|
||||
}, true
|
||||
}
|
||||
|
||||
// PinTaskPluginEndpoint decides shared-endpoint ownership without executing
|
||||
// plugin code. Invalid or unidentifiable ordinary requests deliberately fall
|
||||
// through so the existing endpoint remains responsible for its validation.
|
||||
func PinTaskPluginEndpoint() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
generation := pluginruntime.DefaultRegistry.Generation()
|
||||
if value, exists := c.Get(contextKeyTaskPluginEndpointSelection); exists {
|
||||
if selected, ok := value.(taskPluginEndpointSelection); ok {
|
||||
generation = selected.Generation
|
||||
}
|
||||
}
|
||||
if generation == nil {
|
||||
c.Next()
|
||||
return
|
||||
@@ -347,47 +445,22 @@ func PinTaskPluginEndpoint() gin.HandlerFunc {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
lookupModel := claimedModel
|
||||
pinModel := claimedModel
|
||||
mappedModel := ""
|
||||
rewriteTo := ""
|
||||
if declared, ok := generation.CanonicalModel(claimedModel); ok {
|
||||
lookupModel = declared
|
||||
pinModel = declared
|
||||
if claimedModel != declared {
|
||||
rewriteTo = declared
|
||||
}
|
||||
} else if target, ok := model.ResolveTaskModelAlias(generation, claimedModel); ok {
|
||||
if target.Declared == "" {
|
||||
c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
lookupModel = target.Declared
|
||||
pinModel = target.Alias
|
||||
mappedModel = target.Declared
|
||||
if claimedModel != target.Alias {
|
||||
rewriteTo = target.Alias
|
||||
}
|
||||
}
|
||||
binding, found := generation.LookupEndpoint(c.Request.Method, c.Request.URL.Path, lookupModel)
|
||||
if !found || binding.Plugin == nil {
|
||||
pinnedEndpoint, found := lookupTaskPluginEndpoint(generation, c.Request.Method, c.Request.URL.Path, claimedModel)
|
||||
if !found {
|
||||
c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if rewriteTo != "" {
|
||||
if rewriteErr := rewriteTaskPluginJSONModel(c, rewriteTo); rewriteErr != nil {
|
||||
if claimedModel != pinnedEndpoint.Model {
|
||||
if rewriteErr := rewriteTaskPluginJSONModel(c, pinnedEndpoint.Model); rewriteErr != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid task protocol request")
|
||||
return
|
||||
}
|
||||
}
|
||||
modelRequest.Model = pinModel
|
||||
modelRequest.Model = pinnedEndpoint.Model
|
||||
c.Set(contextKeyTaskPluginEndpointModel, *modelRequest)
|
||||
candidates := generation.LookupEndpointCandidates(c.Request.Method, c.Request.URL.Path, lookupModel)
|
||||
if len(candidates) == 0 {
|
||||
candidates = []pluginruntime.ProtocolBinding{binding}
|
||||
}
|
||||
candidates := pinnedEndpoint.Candidates
|
||||
binding := candidates[0]
|
||||
if definition, known := pluginruntime.HostProtocol(binding.Protocol); known && len(definition.DefinedModes()) > 0 {
|
||||
stream, background := jsonBodyBoolFlags(c)
|
||||
required := make([]string, 0, 2)
|
||||
@@ -426,15 +499,10 @@ func PinTaskPluginEndpoint() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
pin := pluginruntime.PinnedPlugin{Generation: generation, Plugin: binding.Plugin}
|
||||
pinnedEndpoint := pluginruntime.PinnedEndpoint{
|
||||
Generation: generation,
|
||||
Plugin: binding.Plugin,
|
||||
Protocol: binding.Protocol,
|
||||
Operation: binding.Operation,
|
||||
Model: pinModel,
|
||||
MappedModel: mappedModel,
|
||||
Candidates: candidates,
|
||||
}
|
||||
pinnedEndpoint.Plugin = binding.Plugin
|
||||
pinnedEndpoint.Protocol = binding.Protocol
|
||||
pinnedEndpoint.Operation = binding.Operation
|
||||
pinnedEndpoint.Candidates = candidates
|
||||
c.Set(pluginruntime.ContextKeyPinnedPlugin, pin)
|
||||
c.Set(pluginruntime.ContextKeyPinnedEndpoint, pinnedEndpoint)
|
||||
logger.LogDebug(
|
||||
@@ -445,7 +513,7 @@ func PinTaskPluginEndpoint() gin.HandlerFunc {
|
||||
binding.Plugin.Meta.Version,
|
||||
binding.Operation.Methods[0],
|
||||
binding.Protocol,
|
||||
pinModel,
|
||||
pinnedEndpoint.Model,
|
||||
)
|
||||
c.Next()
|
||||
}
|
||||
@@ -888,14 +956,11 @@ func buildTaskPluginRouteRequest(c *gin.Context) (pluginruntime.RouteRequestCont
|
||||
part.Close()
|
||||
return requestContext, fmt.Errorf("request body exceeds %d fields", maxTaskPluginFormFields)
|
||||
}
|
||||
value, readErr := io.ReadAll(io.LimitReader(part, maxTaskPluginFieldValueBytes+1))
|
||||
value, readErr := io.ReadAll(part)
|
||||
part.Close()
|
||||
if readErr != nil {
|
||||
return requestContext, readErr
|
||||
}
|
||||
if len(value) > maxTaskPluginFieldValueBytes {
|
||||
return requestContext, fmt.Errorf("request field %q exceeds %d bytes", name, maxTaskPluginFieldValueBytes)
|
||||
}
|
||||
if !utf8.Valid(value) {
|
||||
return requestContext, fmt.Errorf("request field %q must be valid UTF-8", name)
|
||||
}
|
||||
@@ -991,9 +1056,6 @@ func validateTaskPluginFields(fields url.Values) error {
|
||||
if !utf8.ValidString(value) {
|
||||
return fmt.Errorf("request field %q must be valid UTF-8", name)
|
||||
}
|
||||
if len(value) > maxTaskPluginFieldValueBytes {
|
||||
return fmt.Errorf("request field %q exceeds %d bytes", name, maxTaskPluginFieldValueBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
+335
-10
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
@@ -21,12 +22,14 @@ import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
builtinplugins "github.com/QuantumNous/new-api/plugins"
|
||||
kitdto "github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
const genericTaskPluginSource = `
|
||||
@@ -178,15 +181,27 @@ export function parseTaskResult() { return {status: "SUCCESS"}; }
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrepareTaskPluginNativeRouteRejectsMultipartBeforeDecoder(t *testing.T) {
|
||||
func TestPrepareTaskPluginNativeRoutePassesMultipartToDecoder(t *testing.T) {
|
||||
plugin := compileTaskRoutePlugin(t, `
|
||||
export const meta = {
|
||||
apiVersion: 1, key: "route-multipart-test", name: "Multipart", version: "1.0.0",
|
||||
author: {name: "Test"},
|
||||
models: ["multipart-model"], fetchMode: "per_task",
|
||||
routes: [{method: "POST", path: "/vendor/uploads", type: "submit", decode: "decodeUpload", render: "created"}],
|
||||
routes: [
|
||||
{method: "POST", path: "/vendor/uploads", type: "submit", decode: "decodeUpload", render: "created"},
|
||||
{method: "POST", path: "/vendor/scoped", type: "submit", decode: "decodeUpload", render: "created", models: ["multipart-model"]},
|
||||
],
|
||||
};
|
||||
export const native = {
|
||||
decodeUpload: function(ctx) {
|
||||
if (ctx.body.kind !== "multipart") throw new Error("multipart body expected");
|
||||
return {kind: "submit", model: "multipart-model", requestBody: {
|
||||
caption: ctx.body.fields.caption[0], tags: ctx.body.fields.tag.slice(),
|
||||
files: ctx.body.files.map(function(file) { return [file.ref, file.filename, file.size]; }),
|
||||
}};
|
||||
},
|
||||
created: function(ctx, task) { return task; },
|
||||
};
|
||||
export const native = {decodeUpload: function() { throw new Error("decoder must not run"); }, created: function(ctx, task) { return task; }};
|
||||
export function buildSubmitRequest() { return {url: "https://example.com"}; }
|
||||
export function parseSubmitResponse() { return {taskId: "one"}; }
|
||||
export function buildQueryRequest() { return {url: "https://example.com"}; }
|
||||
@@ -204,7 +219,9 @@ export function parseTaskResult() { return {status: "SUCCESS"}; }
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
router := gin.New()
|
||||
var taskRequest any
|
||||
router.POST("/vendor/uploads", pinTaskPluginRoute(plugin, 0), PrepareTaskPluginRoute(), func(c *gin.Context) {
|
||||
taskRequest, _ = c.Get("task_request")
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodPost, "/vendor/uploads", bytes.NewReader(body.Bytes()))
|
||||
@@ -213,7 +230,29 @@ export function parseTaskResult() { return {status: "SUCCESS"}; }
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusNoContent, recorder.Code, recorder.Body.String())
|
||||
assert.Equal(t, map[string]any{
|
||||
"caption": "hello",
|
||||
"tags": []any{"one", "two"},
|
||||
"files": []any{[]any{"request_file:media", "clip.bin", int64(11)}},
|
||||
}, taskRequest)
|
||||
|
||||
form := httptest.NewRequest(http.MethodPost, "/vendor/uploads", strings.NewReader("caption=hello"))
|
||||
form.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
recorder = httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, form)
|
||||
assert.Equal(t, http.StatusUnsupportedMediaType, recorder.Code, "submit routes still refuse url-encoded forms")
|
||||
|
||||
// A route scoped to models reads the model from a JSON body.
|
||||
router.POST("/vendor/scoped", pinTaskPluginRoute(plugin, 1), PrepareTaskPluginRoute(), func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
scoped := httptest.NewRequest(http.MethodPost, "/vendor/scoped", bytes.NewReader(body.Bytes()))
|
||||
scoped.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
recorder = httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, scoped)
|
||||
assert.Equal(t, http.StatusUnsupportedMediaType, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "this route requires a JSON body")
|
||||
}
|
||||
|
||||
func TestPrepareTaskPluginRouteModelScope(t *testing.T) {
|
||||
@@ -402,7 +441,6 @@ func TestBuildTaskPluginRouteRequestRejectsUnsafeBodies(t *testing.T) {
|
||||
{name: "invalid json UTF-8", contentType: "application/json", body: []byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'}, errorText: "valid UTF-8"},
|
||||
{name: "invalid form UTF-8", contentType: "application/x-www-form-urlencoded", body: []byte("x=%FF"), errorText: "valid UTF-8"},
|
||||
{name: "too many repeated form fields", contentType: "application/x-www-form-urlencoded", body: []byte(strings.Repeat("x=v&", maxTaskPluginFormFields) + "x=v"), errorText: "exceeds 256 fields"},
|
||||
{name: "oversized form field", contentType: "application/x-www-form-urlencoded", body: []byte("x=" + strings.Repeat("a", maxTaskPluginFieldValueBytes+1)), errorText: "exceeds 1048576 bytes"},
|
||||
{name: "missing multipart boundary", contentType: "multipart/form-data", body: []byte("body"), errorText: "boundary is required"},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
@@ -456,12 +494,6 @@ func TestBuildTaskPluginRouteRequestRejectsUnsafeBodies(t *testing.T) {
|
||||
_, err = part.Write([]byte{0xff})
|
||||
require.NoError(t, err)
|
||||
}, errorText: "valid UTF-8"},
|
||||
{name: "oversized multipart field", build: func(t *testing.T, writer *multipart.Writer) {
|
||||
part, err := writer.CreateFormField("field")
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write([]byte(strings.Repeat("a", maxTaskPluginFieldValueBytes+1)))
|
||||
require.NoError(t, err)
|
||||
}, errorText: "exceeds 1048576 bytes"},
|
||||
{name: "nested multipart", build: func(t *testing.T, writer *multipart.Writer) {
|
||||
header := make(textproto.MIMEHeader)
|
||||
header.Set("Content-Disposition", `form-data; name="nested"`)
|
||||
@@ -506,6 +538,30 @@ func TestBuildTaskPluginRouteRequestRejectsUnsafeBodies(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("multipart fields are bounded by the request body limit only", func(t *testing.T) {
|
||||
previous := constant.MaxRequestBodyMB
|
||||
t.Cleanup(func() { constant.MaxRequestBodyMB = previous })
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
// A JSON request sent as a field, larger than the old 1 MiB field cap.
|
||||
require.NoError(t, writer.WriteField("request", strings.Repeat("a", 2<<20)))
|
||||
require.NoError(t, writer.Close())
|
||||
send := func(limitMB int) (map[string]any, error) {
|
||||
constant.MaxRequestBodyMB = limitMB
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/body", bytes.NewReader(body.Bytes()))
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
request, err := buildTaskPluginRouteRequest(c)
|
||||
decoded, _ := request.Body.(map[string]any)
|
||||
return decoded, err
|
||||
}
|
||||
decoded, err := send(4)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, decoded["fields"].(map[string][]string)["request"][0], 2<<20)
|
||||
_, err = send(1)
|
||||
require.ErrorContains(t, err, "request body exceeds 1 MB")
|
||||
})
|
||||
|
||||
t.Run("oversized multipart file", func(t *testing.T) {
|
||||
previous := constant.MaxFileDownloadMB
|
||||
constant.MaxFileDownloadMB = 1
|
||||
@@ -1998,3 +2054,272 @@ func TestPrepareTaskPluginEndpointFiltersEachSharedCandidate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteTaskPluginEndpointUsesSelectedChannel(t *testing.T) {
|
||||
require.NoError(t, appI18n.Init())
|
||||
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
dsn := ""
|
||||
if dialect != "sqlite" {
|
||||
dsn = os.Getenv("TEST_" + strings.ToUpper(dialect) + "_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("database test DSN not configured")
|
||||
}
|
||||
}
|
||||
previousDB, previousMain, previousLog := model.DB, common.MainDatabaseType(), common.LogDatabaseType()
|
||||
previousPath, previousMaster := common.SQLitePath, common.IsMasterNode
|
||||
previousCache, previousRedis := common.MemoryCacheEnabled, common.RedisEnabled
|
||||
common.SQLitePath, common.IsMasterNode = t.TempDir()+"/routing.db", false
|
||||
common.RedisEnabled = false
|
||||
t.Setenv("SQL_DSN", dsn)
|
||||
require.NoError(t, model.InitDB())
|
||||
database := model.DB
|
||||
// Every table belongs to this run, including when a developer points
|
||||
// a test DSN at a database shared with another test suite.
|
||||
database.Config.NamingStrategy = schema.NamingStrategy{TablePrefix: "endpoint_route_" + common.GetUUID() + "_"}
|
||||
require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.Task{}))
|
||||
t.Cleanup(func() {
|
||||
for _, table := range []any{&model.Ability{}, &model.Channel{}, &model.Task{}} {
|
||||
require.NoError(t, database.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(table).Error)
|
||||
}
|
||||
common.MemoryCacheEnabled = true
|
||||
model.InitChannelCache()
|
||||
require.NoError(t, database.Migrator().DropTable(&model.Ability{}, &model.Channel{}, &model.Task{}))
|
||||
model.DB = previousDB
|
||||
common.SetDatabaseTypes(previousMain, previousLog)
|
||||
common.SQLitePath, common.IsMasterNode = previousPath, previousMaster
|
||||
common.MemoryCacheEnabled, common.RedisEnabled = previousCache, previousRedis
|
||||
if previousDB != nil {
|
||||
model.InitChannelCache()
|
||||
}
|
||||
sqlDB, err := database.DB()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, sqlDB.Close())
|
||||
})
|
||||
query := "SELECT version()"
|
||||
if dialect == "sqlite" {
|
||||
query = "SELECT sqlite_version()"
|
||||
}
|
||||
var version string
|
||||
require.NoError(t, database.Raw(query).Scan(&version).Error)
|
||||
t.Logf("database version: %s", version)
|
||||
|
||||
for _, memoryCache := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("memory_cache=%t", memoryCache), func(t *testing.T) {
|
||||
common.MemoryCacheEnabled = memoryCache
|
||||
for _, tc := range []struct {
|
||||
name, path, model string
|
||||
pluginType int
|
||||
ordinaryType int
|
||||
ordinaryPriority int64
|
||||
pluginPriority int64
|
||||
pluginOnly, wantPlugin bool
|
||||
multipart, stream bool
|
||||
pinOrdinary, pinPlugin bool
|
||||
wrongGroup, wrongModel bool
|
||||
disabled, unrelated bool
|
||||
decoderRejects, syncOnly bool
|
||||
originPin, retry bool
|
||||
legacy, mapped, shared bool
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "ordinary images bypass plugin decoder", decoderRejects: true},
|
||||
{name: "ordinary images win by priority", ordinaryPriority: 20},
|
||||
{name: "plugin images win by priority", pluginPriority: 20, wantPlugin: true},
|
||||
{name: "type 61 plugin remains usable", pluginOnly: true, wantPlugin: true},
|
||||
{name: "Alibaba legacy channel remains usable", legacy: true, pluginOnly: true, wantPlugin: true},
|
||||
{name: "OpenAI can win against Alibaba for qwen-image-3.0", legacy: true, ordinaryPriority: 20},
|
||||
{name: "Alibaba can win against OpenAI for qwen-image-3.0", legacy: true, pluginPriority: 20, wantPlugin: true},
|
||||
{name: "bound New API uses plugin", pluginType: constant.ChannelTypeNewAPI, pluginPriority: 20, wantPlugin: true},
|
||||
{name: "unbound New API uses ordinary relay", ordinaryType: constant.ChannelTypeNewAPI, decoderRejects: true},
|
||||
{name: "unrelated type 61 cannot steal request", unrelated: true, pluginPriority: 20},
|
||||
{name: "disabled plugin channel is ignored", disabled: true, pluginPriority: 20},
|
||||
{name: "plugin in another group is ignored", wrongGroup: true, pluginPriority: 20},
|
||||
{name: "plugin without requested model is ignored", wrongModel: true, pluginPriority: 20},
|
||||
{name: "ordinary channel pin wins", pinOrdinary: true, pluginPriority: 20},
|
||||
{name: "plugin channel pin wins", pinPlugin: true, wantPlugin: true},
|
||||
{name: "ordinary model spelling is preserved", model: "CHANNEL-ROUTE-MODEL", decoderRejects: true},
|
||||
{name: "mapped plugin alias remains usable", model: "friendly-image", mapped: true, pluginOnly: true, wantPlugin: true},
|
||||
{name: "ordinary alias is not rewritten by plugin", model: "friendly-image", mapped: true, decoderRejects: true},
|
||||
{name: "ordinary multipart edits are preserved", path: "/v1/images/edits", multipart: true, decoderRejects: true},
|
||||
{name: "plugin multipart edits remain usable", path: "/v1/images/edits", multipart: true, pluginOnly: true, wantPlugin: true},
|
||||
{name: "ordinary Responses bypass unsupported plugin mode", path: "/v1/responses", stream: true, syncOnly: true},
|
||||
{name: "plugin Responses remain usable", path: "/v1/responses", pluginOnly: true, wantPlugin: true},
|
||||
{name: "ordinary videos bypass plugin decoder", path: "/v1/videos", multipart: true, decoderRejects: true},
|
||||
{name: "plugin videos remain usable", path: "/v1/videos", multipart: true, pluginOnly: true, wantPlugin: true},
|
||||
{name: "selected plugin retains validation errors", pluginOnly: true, decoderRejects: true, wantStatus: http.StatusBadRequest},
|
||||
{name: "selected plugin retains mode errors", path: "/v1/responses", pluginOnly: true, stream: true, syncOnly: true, wantStatus: http.StatusBadRequest},
|
||||
{name: "origin task still pins its channel", pluginOnly: true, originPin: true, wantPlugin: true},
|
||||
{name: "ordinary retries stay on ordinary channels", ordinaryPriority: 20, pluginPriority: 10, retry: true},
|
||||
{name: "plugin retries stay on plugin channels", pluginPriority: 20, retry: true, wantPlugin: true},
|
||||
{name: "rejected shared candidate still allows another plugin", pluginOnly: true, shared: true, decoderRejects: true, pluginPriority: 20, wantPlugin: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, table := range []any{&model.Ability{}, &model.Channel{}, &model.Task{}} {
|
||||
require.NoError(t, database.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(table).Error)
|
||||
}
|
||||
key := "channel-route-test"
|
||||
path := common.GetStringIfEmpty(tc.path, "/v1/images/generations")
|
||||
modelName := common.GetStringIfEmpty(tc.model, "channel-route-model")
|
||||
if tc.legacy {
|
||||
key, modelName = "alibaba", "qwen-image-3.0"
|
||||
}
|
||||
decode := `return {model: ctx.model, action: "generate", requestBody: {normalized: true}};`
|
||||
if tc.decoderRejects {
|
||||
decode = `throw new Error("plugin rejected this request");`
|
||||
} else if tc.originPin {
|
||||
decode = `return {model: ctx.model, originTaskIds: ["task-route-origin"], requestBody: {normalized: true}};`
|
||||
}
|
||||
source := taskProtocolPluginSource(key, "1.0.0", `["channel-route-model"]`, path, decode)
|
||||
if tc.syncOnly {
|
||||
source = taskResponsesPluginSource(key, 0, `["channel-route-model"]`, `["sync"]`, `renderFinal: function() { return {}; },`, decode)
|
||||
}
|
||||
if !tc.legacy {
|
||||
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
|
||||
}
|
||||
ordinary := &model.Channel{Type: constant.ChannelTypeOpenAI, Status: common.ChannelStatusEnabled, Name: "ordinary", Key: "ordinary-key", Group: "route-test", Models: modelName, Priority: common.GetPointer(tc.ordinaryPriority + 1)}
|
||||
if tc.ordinaryType != 0 {
|
||||
ordinary.Type = tc.ordinaryType
|
||||
}
|
||||
plugin := &model.Channel{Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Name: "plugin", Key: "plugin-key", Group: "route-test", Models: modelName, Priority: &tc.pluginPriority}
|
||||
if tc.pluginType != 0 {
|
||||
plugin.Type = tc.pluginType
|
||||
}
|
||||
plugin.SetSetting(kitdto.ChannelSettings{TaskPluginKey: key})
|
||||
if tc.legacy {
|
||||
plugin.Type = constant.ChannelTypeAli
|
||||
}
|
||||
if tc.mapped {
|
||||
plugin.ModelMapping = common.GetPointer(`{"friendly-image":"channel-route-model"}`)
|
||||
}
|
||||
if tc.unrelated {
|
||||
plugin.SetSetting(kitdto.ChannelSettings{TaskPluginKey: "unrelated"})
|
||||
}
|
||||
if tc.wrongGroup {
|
||||
plugin.Group = "another-group"
|
||||
}
|
||||
if tc.wrongModel {
|
||||
plugin.Models = "another-model"
|
||||
}
|
||||
if tc.disabled {
|
||||
plugin.Status = common.ChannelStatusManuallyDisabled
|
||||
}
|
||||
channels := []*model.Channel{plugin}
|
||||
if !tc.pluginOnly {
|
||||
channels = append(channels, ordinary)
|
||||
}
|
||||
if tc.originPin || tc.retry || tc.shared {
|
||||
alternate := &model.Channel{Type: ordinary.Type, Status: common.ChannelStatusEnabled, Name: "alternate", Key: "alternate-key", Group: "route-test", Models: modelName}
|
||||
if tc.wantPlugin {
|
||||
alternate.Type = plugin.Type
|
||||
alternate.SetSetting(plugin.GetSetting())
|
||||
}
|
||||
if tc.shared {
|
||||
const alternateKey = "channel-route-ztest"
|
||||
alternateSource := taskProtocolPluginSource(alternateKey, "1.0.0", `["channel-route-model"]`, path, `return {model: ctx.model, requestBody: {normalized: true}};`)
|
||||
_, err := jsplugin.DefaultRegistry.Register(alternateSource, jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(alternateKey)) })
|
||||
alternate.SetSetting(kitdto.ChannelSettings{TaskPluginKey: alternateKey})
|
||||
}
|
||||
channels = append(channels, alternate)
|
||||
}
|
||||
for _, channel := range channels {
|
||||
require.NoError(t, channel.Insert())
|
||||
}
|
||||
if tc.originPin {
|
||||
require.NoError(t, database.Create(&model.Task{TaskID: "task-route-origin", UserId: 7, ChannelId: channels[len(channels)-1].Id, Platform: constant.TaskPlatform(key), Status: model.TaskStatusSuccess}).Error)
|
||||
}
|
||||
model.InitChannelCache()
|
||||
body := []byte(fmt.Sprintf(`{"model":%q,"prompt":"a cat","size":"1024x1024","stream":%t}`, modelName, tc.stream))
|
||||
contentType := "application/json"
|
||||
if tc.multipart {
|
||||
var form bytes.Buffer
|
||||
writer := multipart.NewWriter(&form)
|
||||
require.NoError(t, writer.WriteField("model", modelName))
|
||||
require.NoError(t, writer.WriteField("prompt", "a cat"))
|
||||
file, err := writer.CreateFormFile("image", "reference.png")
|
||||
require.NoError(t, err)
|
||||
_, err = file.Write([]byte("image bytes"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
body, contentType = form.Bytes(), writer.FormDataContentType()
|
||||
}
|
||||
reached := false
|
||||
router := gin.New()
|
||||
router.POST(path, func(c *gin.Context) {
|
||||
common.SetContextKey(c, constant.ContextKeyUsingGroup, "route-test")
|
||||
common.SetContextKey(c, constant.ContextKeyUserId, 7)
|
||||
if tc.pinOrdinary || tc.pinPlugin {
|
||||
id := ordinary.Id
|
||||
if tc.pinPlugin {
|
||||
id = plugin.Id
|
||||
}
|
||||
service.GetChannelConstraints(c).AddPin(dto.ChannelPin{ChannelId: id, Source: dto.PinSourceToken, Rank: dto.PinRankToken})
|
||||
}
|
||||
}, RouteTaskPluginEndpoint(), PrepareTaskPluginEndpoint(), Distribute(), func(c *gin.Context) {
|
||||
reached = true
|
||||
_, pinned := c.Get(jsplugin.ContextKeyPinnedEndpoint)
|
||||
assert.Equal(t, tc.wantPlugin, pinned)
|
||||
_, relayEndpoint := c.Get(jsplugin.ContextKeyRelayEndpoint)
|
||||
assert.Equal(t, !tc.wantPlugin, relayEndpoint)
|
||||
expected := ordinary
|
||||
if tc.wantPlugin {
|
||||
expected = plugin
|
||||
if tc.shared {
|
||||
assert.Equal(t, "channel-route-ztest", c.GetString("task_plugin_key"))
|
||||
} else {
|
||||
assert.Equal(t, key, c.GetString("task_plugin_key"))
|
||||
}
|
||||
if tc.legacy {
|
||||
assert.NotNil(t, c.MustGet("task_request"))
|
||||
} else {
|
||||
assert.Equal(t, map[string]any{"normalized": true}, c.MustGet("task_request"))
|
||||
}
|
||||
if tc.mapped {
|
||||
assert.Equal(t, "channel-route-model", c.MustGet(jsplugin.ContextKeyPinnedEndpoint).(jsplugin.PinnedEndpoint).MappedModel)
|
||||
}
|
||||
if tc.originPin || tc.shared {
|
||||
expected = channels[len(channels)-1]
|
||||
}
|
||||
} else {
|
||||
assert.Empty(t, c.GetString("expected_task_plugin_key"))
|
||||
assert.Empty(t, c.GetString("resolved_task_model"))
|
||||
storage, err := common.GetBodyStorage(c)
|
||||
require.NoError(t, err)
|
||||
got, err := storage.Bytes()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, body, got, "ordinary relay must receive the original body")
|
||||
}
|
||||
assert.Equal(t, expected.Id, common.GetContextKeyInt(c, constant.ContextKeyChannelId))
|
||||
assert.Equal(t, expected.Key, common.GetContextKeyString(c, constant.ContextKeyChannelKey))
|
||||
if tc.retry {
|
||||
next, _, err := service.CacheGetRandomSatisfiedChannel(&service.RetryParam{Ctx: c, TokenGroup: "route-test", ModelName: modelName, Retry: common.GetPointer(1)})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, next)
|
||||
assert.Equal(t, channels[len(channels)-1].Id, next.Id)
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
status := tc.wantStatus
|
||||
if status == 0 {
|
||||
status = http.StatusNoContent
|
||||
}
|
||||
assert.Equal(t, status, recorder.Code, recorder.Body.String())
|
||||
assert.Equal(t, status == http.StatusNoContent, reached)
|
||||
if tc.decoderRejects && status == http.StatusBadRequest {
|
||||
assert.Contains(t, recorder.Body.String(), "plugin rejected this request")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"gorm.io/gorm"
|
||||
@@ -116,6 +117,15 @@ func GetChannel(
|
||||
return nil, err
|
||||
}
|
||||
abilities = filterAbilitiesByConstraints(abilities, model, filters)
|
||||
// Like the memory cache, retry with the routing name (for example without
|
||||
// @ modifiers) when no channel serves the exact name.
|
||||
if normalized := ratio_setting.RoutingMatchModelName(model); len(abilities) == 0 && normalized != "" && normalized != model {
|
||||
err = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, normalized, true).Order("priority DESC, weight DESC").Find(&abilities).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
abilities = filterAbilitiesByConstraints(abilities, model, filters)
|
||||
}
|
||||
if len(abilities) > 0 {
|
||||
priorities := make([]int64, 0)
|
||||
seen := make(map[int64]bool)
|
||||
|
||||
+2
-2
@@ -25,11 +25,11 @@ const (
|
||||
// TokenRef identifies a PAT generation without storing its bearer credential.
|
||||
type AuditLog struct {
|
||||
Id int `json:"id"`
|
||||
EventId string `json:"event_id" gorm:"type:varchar(64);uniqueIndex"`
|
||||
EventId string `json:"event_id" gorm:"type:varchar(64);uniqueIndex;index:idx_audit_time_event,priority:2"`
|
||||
UserId int `json:"user_id" gorm:"index:idx_audit_user_time,priority:1"`
|
||||
Username string `json:"username" gorm:"type:varchar(64);index"`
|
||||
ActorRole int `json:"actor_role"` // Immutable role of the actor when the event began, not the log owner.
|
||||
CreatedAt int64 `json:"created_at" gorm:"type:bigint;index:idx_audit_user_time,priority:2;index:idx_audit_token_time,priority:2;index"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"type:bigint;index:idx_audit_user_time,priority:2;index:idx_audit_token_time,priority:2;index;index:idx_audit_time_event,priority:1"`
|
||||
Category string `json:"category" gorm:"type:varchar(24);index"`
|
||||
Action string `json:"action" gorm:"type:varchar(128)"`
|
||||
TokenRef string `json:"token_ref" gorm:"type:varchar(64);index:idx_audit_token_time,priority:1"`
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// TestAuditLogSortIndexDatabaseMatrix verifies audit ordering and index migrations across supported databases.
|
||||
func TestAuditLogSortIndexDatabaseMatrix(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
var driver gorm.Dialector
|
||||
switch dialect {
|
||||
case "sqlite":
|
||||
driver = sqlite.Open(":memory:")
|
||||
case "mysql":
|
||||
dsn := os.Getenv("TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_MYSQL_DSN is not configured")
|
||||
}
|
||||
driver = mysql.Open(dsn)
|
||||
case "postgres":
|
||||
dsn := os.Getenv("TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_POSTGRES_DSN is not configured")
|
||||
}
|
||||
driver = postgres.Open(dsn)
|
||||
}
|
||||
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "audit_sort_test_"}})
|
||||
require.NoError(t, err)
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
previousType := common.LogDatabaseType()
|
||||
common.SetLogDatabaseType(common.DatabaseType(dialect))
|
||||
t.Cleanup(func() {
|
||||
common.SetLogDatabaseType(previousType)
|
||||
assert.NoError(t, sqlDB.Close())
|
||||
})
|
||||
versionSQL := "SELECT version()"
|
||||
if dialect == "sqlite" {
|
||||
versionSQL = "SELECT sqlite_version()"
|
||||
}
|
||||
var version string
|
||||
require.NoError(t, db.Raw(versionSQL).Scan(&version).Error)
|
||||
t.Logf("database version: %s", version)
|
||||
for _, upgrade := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("upgrade=%v", upgrade), func(t *testing.T) {
|
||||
db := db
|
||||
if dialect == "postgres" {
|
||||
db = db.Begin()
|
||||
require.NoError(t, db.Error)
|
||||
t.Cleanup(func() { assert.NoError(t, db.Rollback().Error) })
|
||||
schemaName := fmt.Sprintf("audit_sort_test_%d", time.Now().UnixNano())
|
||||
require.NoError(t, db.Exec("CREATE SCHEMA ?", clause.Table{Name: schemaName}).Error)
|
||||
require.NoError(t, db.Exec("SET LOCAL search_path TO ?", clause.Table{Name: schemaName}).Error)
|
||||
} else {
|
||||
t.Cleanup(func() { assert.NoError(t, db.Migrator().DropTable(&AuditLog{})) })
|
||||
}
|
||||
previousLogDB := LOG_DB
|
||||
LOG_DB = db
|
||||
t.Cleanup(func() { LOG_DB = previousLogDB })
|
||||
require.NoError(t, MigrateAuditLogs())
|
||||
if upgrade {
|
||||
require.NoError(t, db.Migrator().DropIndex(&AuditLog{}, "idx_audit_time_event"))
|
||||
}
|
||||
entries := []AuditLog{
|
||||
{EventId: "event-older", CreatedAt: 100, UserId: 1, ActorRole: common.RoleCommonUser},
|
||||
{EventId: "event-a", CreatedAt: 200, UserId: 1, ActorRole: common.RoleCommonUser},
|
||||
{EventId: "event-z", CreatedAt: 200, UserId: 1, ActorRole: common.RoleCommonUser},
|
||||
}
|
||||
require.NoError(t, db.Create(&entries).Error)
|
||||
originalIndexes, err := db.Migrator().GetIndexes(&AuditLog{})
|
||||
require.NoError(t, err)
|
||||
for range 2 {
|
||||
require.NoError(t, MigrateAuditLogs())
|
||||
}
|
||||
indexes, err := db.Migrator().GetIndexes(&AuditLog{})
|
||||
require.NoError(t, err)
|
||||
byName := make(map[string]gorm.Index, len(indexes))
|
||||
for _, index := range indexes {
|
||||
byName[index.Name()] = index
|
||||
}
|
||||
index, ok := byName["idx_audit_time_event"]
|
||||
require.True(t, ok, "migration must create the audit ordering index")
|
||||
var columns []string
|
||||
if dialect == "postgres" {
|
||||
// GORM's PostgreSQL GetIndexes does not preserve index key order.
|
||||
require.NoError(t, db.Raw(`
|
||||
SELECT attribute.attname
|
||||
FROM pg_catalog.pg_index AS index_meta
|
||||
CROSS JOIN LATERAL unnest(index_meta.indkey) WITH ORDINALITY AS index_column(attnum, ordinal_position)
|
||||
JOIN pg_catalog.pg_attribute AS attribute
|
||||
ON attribute.attrelid = index_meta.indrelid AND attribute.attnum = index_column.attnum
|
||||
WHERE index_meta.indexrelid = to_regclass(?)
|
||||
ORDER BY index_column.ordinal_position
|
||||
`, index.Name()).Scan(&columns).Error)
|
||||
} else {
|
||||
columns = index.Columns()
|
||||
}
|
||||
assert.Equal(t, []string{"created_at", "event_id"}, columns)
|
||||
for _, original := range originalIndexes {
|
||||
preserved, ok := byName[original.Name()]
|
||||
require.True(t, ok, "existing index %s must remain", original.Name())
|
||||
assert.Equal(t, original.Columns(), preserved.Columns(), original.Name())
|
||||
originalUnique, originalKnown := original.Unique()
|
||||
preservedUnique, preservedKnown := preserved.Unique()
|
||||
assert.Equal(t, originalKnown, preservedKnown, original.Name())
|
||||
assert.Equal(t, originalUnique, preservedUnique, original.Name())
|
||||
}
|
||||
var stored []AuditLog
|
||||
require.NoError(t, db.Order("id").Find(&stored).Error)
|
||||
assert.Equal(t, entries, stored, "migration must preserve existing audit records")
|
||||
logs, total, err := GetAuditLogs(AuditLogFilter{}, 0, 2, common.RoleRootUser)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 3, total)
|
||||
require.Len(t, logs, 2)
|
||||
assert.Equal(t, []string{"event-z", "event-a"}, []string{logs[0].EventId, logs[1].EventId})
|
||||
logs, total, err = GetAuditLogs(AuditLogFilter{}, 2, 2, common.RoleRootUser)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 3, total)
|
||||
require.Len(t, logs, 1)
|
||||
assert.Equal(t, "event-older", logs[0].EventId)
|
||||
assert.Error(t, db.Create(&AuditLog{EventId: "event-a"}).Error, "event IDs must remain unique")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+9
-4
@@ -105,10 +105,15 @@ func NewChannelSortOptions(sortBy string, sortOrder string, idSort bool) Channel
|
||||
|
||||
func (options ChannelSortOptions) Apply(query *gorm.DB) *gorm.DB {
|
||||
if columnName, ok := channelSortColumns[options.SortBy]; ok {
|
||||
column := clause.Column{Name: columnName}
|
||||
if columnName == "priority" {
|
||||
// Routing treats a missing priority as zero on every database.
|
||||
column = clause.Column{Name: "COALESCE(priority, 0)", Raw: true}
|
||||
}
|
||||
return query.Order(clause.OrderByColumn{
|
||||
Column: clause.Column{Name: columnName},
|
||||
Column: column,
|
||||
Desc: options.SortOrder != "asc",
|
||||
})
|
||||
}).Order(clause.OrderByColumn{Column: clause.Column{Name: "id"}, Desc: true})
|
||||
}
|
||||
if options.IDSort {
|
||||
return query.Order(clause.OrderByColumn{
|
||||
@@ -117,9 +122,9 @@ func (options ChannelSortOptions) Apply(query *gorm.DB) *gorm.DB {
|
||||
})
|
||||
}
|
||||
return query.Order(clause.OrderByColumn{
|
||||
Column: clause.Column{Name: "priority"},
|
||||
Column: clause.Column{Name: "COALESCE(priority, 0)", Raw: true},
|
||||
Desc: true,
|
||||
})
|
||||
}).Order(clause.OrderByColumn{Column: clause.Column{Name: "id"}, Desc: true})
|
||||
}
|
||||
|
||||
func resolveChannelSortOptions(idSort bool, sortOptions []ChannelSortOptions) ChannelSortOptions {
|
||||
|
||||
@@ -9,13 +9,14 @@ import (
|
||||
|
||||
var filterEvalOrder = []dto.ChannelFilterKind{
|
||||
dto.FilterRequestPath,
|
||||
dto.FilterTaskPluginEndpoint,
|
||||
dto.FilterTaskPluginIdentity,
|
||||
dto.FilterResponsesWebSocket,
|
||||
}
|
||||
|
||||
// ChannelSatisfiesFilters reports whether ch passes every filter.
|
||||
// On false, it returns the kind of the first violated filter (request_path
|
||||
// then task_plugin_identity) for error attribution.
|
||||
// On false, it returns the first violated filter in filterEvalOrder for error
|
||||
// attribution.
|
||||
func ChannelSatisfiesFilters(ch *Channel, modelName string, filters []dto.ChannelFilter) (bool, dto.ChannelFilterKind) {
|
||||
if ch == nil {
|
||||
return false, ""
|
||||
@@ -36,7 +37,7 @@ func ChannelSatisfiesFilters(ch *Channel, modelName string, filters []dto.Channe
|
||||
// filterCandidateIDs applies filters to a cached candidate id list.
|
||||
// Caller must hold channelSyncLock (read lock). The input slice is never mutated.
|
||||
// A missing id in channelsIDM is kept for request_path (downstream consistency
|
||||
// error) and dropped for task_plugin_identity, matching the previous filters.
|
||||
// error) and dropped for the other filters.
|
||||
func filterCandidateIDs(ids []int, modelName string, filters []dto.ChannelFilter) (kept []int, emptiedBy dto.ChannelFilterKind) {
|
||||
if len(ids) == 0 {
|
||||
return ids, ""
|
||||
@@ -109,6 +110,18 @@ func channelMatchesFilter(ch *Channel, modelName string, filter dto.ChannelFilte
|
||||
return setting.BindsTaskPlugin(filter.TaskPluginKey) || slices.ContainsFunc(filter.TaskPluginKeys, setting.BindsTaskPlugin)
|
||||
}
|
||||
return slices.Contains(filter.TaskPluginChannelTypes, ch.Type)
|
||||
case dto.FilterTaskPluginEndpoint:
|
||||
pluginChannel := slices.Contains(filter.TaskPluginChannelTypes, ch.Type)
|
||||
if ch.Type == constant.ChannelTypeTaskPlugin || ch.Type == constant.ChannelTypeNewAPI {
|
||||
pluginChannel = slices.ContainsFunc(filter.TaskPluginKeys, ch.GetSetting().BindsTaskPlugin)
|
||||
}
|
||||
if filter.TaskPluginRelayOnly {
|
||||
return ch.Type != constant.ChannelTypeTaskPlugin && !pluginChannel
|
||||
}
|
||||
// Ordinary channels and matching plugin channels compete using the same
|
||||
// group, priority, weight, affinity, and pin rules. Unrelated type-61
|
||||
// plugins cannot execute this endpoint.
|
||||
return ch.Type != constant.ChannelTypeTaskPlugin || pluginChannel
|
||||
case dto.FilterResponsesWebSocket:
|
||||
if !ch.GetSetting().ResponsesWebSocketEnabled {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Keep priorities lossless in the web console's JSON number representation.
|
||||
const maxSortablePriority int64 = 1<<53 - 1
|
||||
|
||||
var ErrChannelPriorityConflict = errors.New("Channel priorities changed. Refresh and try again.")
|
||||
var ErrChannelPriorityInvalid = errors.New("Invalid channel priority move")
|
||||
var ErrChannelPriorityRange = errors.New("Channel priority is outside the sortable range")
|
||||
|
||||
type ChannelPriority struct {
|
||||
ID int `json:"id"`
|
||||
Priority *int64 `json:"priority"`
|
||||
}
|
||||
|
||||
func (item ChannelPriority) value() int64 {
|
||||
if item.Priority == nil {
|
||||
return 0
|
||||
}
|
||||
return *item.Priority
|
||||
}
|
||||
|
||||
type ChannelPriorityChange struct {
|
||||
ID int `json:"id"`
|
||||
Previous *int64 `json:"previous"`
|
||||
Priority *int64 `json:"priority"`
|
||||
}
|
||||
|
||||
type ChannelPriorityOrder struct {
|
||||
Revision string `json:"revision"`
|
||||
Items []ChannelPriority `json:"items"`
|
||||
Changes []ChannelPriorityChange `json:"changes"`
|
||||
}
|
||||
|
||||
type ChannelPriorityMove struct {
|
||||
Revision string `json:"revision"`
|
||||
ID int `json:"id"`
|
||||
TargetID int `json:"target_id"`
|
||||
Position string `json:"position"` // before / after use descending priority order
|
||||
Preview bool `json:"preview"`
|
||||
Restore []ChannelPriorityChange `json:"restore,omitempty"`
|
||||
}
|
||||
|
||||
func channelPriorityRevision(items []ChannelPriority) string {
|
||||
data, _ := common.Marshal(items)
|
||||
return fmt.Sprintf("%x", sha256.Sum256(data))
|
||||
}
|
||||
|
||||
func GetChannelPriorityOrder() (ChannelPriorityOrder, error) {
|
||||
result := ChannelPriorityOrder{Items: []ChannelPriority{}, Changes: []ChannelPriorityChange{}}
|
||||
err := DB.Model(&Channel{}).Select("id", "priority").Order("id").Find(&result.Items).Error
|
||||
result.Revision = channelPriorityRevision(result.Items)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// ReorderChannelPriority updates only priorities, including the routing copy in
|
||||
// abilities. A snapshot check also protects preview/commit and undo from stale
|
||||
// clients; row locks serialize concurrent reorders on MySQL and PostgreSQL.
|
||||
func ReorderChannelPriority(move ChannelPriorityMove) (ChannelPriorityOrder, error) {
|
||||
result := ChannelPriorityOrder{Items: []ChannelPriority{}, Changes: []ChannelPriorityChange{}}
|
||||
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||
query := tx.Model(&Channel{}).Select("id", "priority").Order("id")
|
||||
if !move.Preview {
|
||||
query = lockForUpdate(query)
|
||||
}
|
||||
if err := query.Find(&result.Items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if move.Revision == "" || move.Revision != channelPriorityRevision(result.Items) {
|
||||
return ErrChannelPriorityConflict
|
||||
}
|
||||
changes, err := planChannelPriorityMove(result.Items, move)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.Changes = changes
|
||||
byID := make(map[int]*ChannelPriority, len(result.Items))
|
||||
for i := range result.Items {
|
||||
byID[result.Items[i].ID] = &result.Items[i]
|
||||
}
|
||||
for _, change := range changes {
|
||||
byID[change.ID].Priority = change.Priority
|
||||
if move.Preview {
|
||||
continue
|
||||
}
|
||||
if err := tx.Model(&Channel{}).Where("id = ?", change.ID).Update("priority", change.Priority).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&Ability{}).Where("channel_id = ?", change.ID).Update("priority", change.Priority).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result.Revision = channelPriorityRevision(result.Items)
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Equal-priority channels remain together. Opening a slot shifts the shorter
|
||||
// contiguous run of occupied integer priorities, measured by affected channels.
|
||||
func planChannelPriorityMove(items []ChannelPriority, move ChannelPriorityMove) ([]ChannelPriorityChange, error) {
|
||||
changes := []ChannelPriorityChange{}
|
||||
byID := make(map[int]ChannelPriority, len(items))
|
||||
for _, item := range items {
|
||||
if item.value() < -maxSortablePriority || item.value() > maxSortablePriority {
|
||||
return nil, ErrChannelPriorityRange
|
||||
}
|
||||
byID[item.ID] = item
|
||||
}
|
||||
if len(move.Restore) > 0 {
|
||||
if move.ID != 0 || move.TargetID != 0 || move.Position != "" {
|
||||
return nil, ErrChannelPriorityInvalid
|
||||
}
|
||||
seen := make(map[int]bool, len(move.Restore))
|
||||
for _, change := range move.Restore {
|
||||
item, ok := byID[change.ID]
|
||||
if !ok || seen[change.ID] || (change.Priority != nil && (*change.Priority < -maxSortablePriority || *change.Priority > maxSortablePriority)) {
|
||||
return nil, ErrChannelPriorityInvalid
|
||||
}
|
||||
if !equalPriority(item.Priority, change.Previous) {
|
||||
return nil, ErrChannelPriorityConflict
|
||||
}
|
||||
seen[change.ID] = true
|
||||
if !equalPriority(item.Priority, change.Priority) {
|
||||
changes = append(changes, change)
|
||||
}
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
item, ok := byID[move.ID]
|
||||
target, targetOK := byID[move.TargetID]
|
||||
if !ok || !targetOK || move.ID == move.TargetID || !slices.Contains([]string{"before", "after", "same"}, move.Position) {
|
||||
return nil, ErrChannelPriorityInvalid
|
||||
}
|
||||
values := make(map[int]int64, len(items))
|
||||
for _, entry := range items {
|
||||
values[entry.ID] = entry.value()
|
||||
}
|
||||
if move.Position == "same" {
|
||||
values[item.ID] = target.value()
|
||||
} else {
|
||||
counts := make(map[int64]int)
|
||||
for _, entry := range items {
|
||||
if entry.ID != item.ID {
|
||||
counts[entry.value()]++
|
||||
}
|
||||
}
|
||||
levels := make([]int64, 0, len(counts))
|
||||
for level := range counts {
|
||||
levels = append(levels, level)
|
||||
}
|
||||
slices.SortFunc(levels, func(a, b int64) int {
|
||||
if a > b {
|
||||
return -1
|
||||
}
|
||||
if a < b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
index := slices.Index(levels, target.value())
|
||||
if move.Position == "after" {
|
||||
index++
|
||||
}
|
||||
hi, lo := maxSortablePriority+1, -maxSortablePriority-1
|
||||
if index > 0 {
|
||||
hi = levels[index-1]
|
||||
}
|
||||
if index < len(levels) {
|
||||
lo = levels[index]
|
||||
}
|
||||
// Dropping into the channel's existing slot must not renumber it.
|
||||
if item.value() < hi && item.value() > lo {
|
||||
return changes, nil
|
||||
}
|
||||
if hi-lo < 2 {
|
||||
up, down := index-1, index
|
||||
upCost, downCost := 0, 0
|
||||
for up >= 0 {
|
||||
upCost += counts[levels[up]]
|
||||
if (up == 0 && levels[up] < maxSortablePriority) || (up > 0 && levels[up-1]-levels[up] > 1) {
|
||||
break
|
||||
}
|
||||
up--
|
||||
}
|
||||
for down < len(levels) {
|
||||
downCost += counts[levels[down]]
|
||||
if (down == len(levels)-1 && levels[down] > -maxSortablePriority) || (down+1 < len(levels) && levels[down]-levels[down+1] > 1) {
|
||||
break
|
||||
}
|
||||
down++
|
||||
}
|
||||
canUp, canDown := up >= 0 && index > 0, down < len(levels) && index < len(levels)
|
||||
if !canUp && !canDown {
|
||||
return nil, ErrChannelPriorityRange
|
||||
}
|
||||
if canUp && (!canDown || upCost < downCost) {
|
||||
for id, value := range values {
|
||||
if id != item.ID && value >= levels[index-1] && value <= levels[up] {
|
||||
values[id]++
|
||||
}
|
||||
}
|
||||
hi++
|
||||
} else {
|
||||
for id, value := range values {
|
||||
if id != item.ID && value <= levels[index] && value >= levels[down] {
|
||||
values[id]--
|
||||
}
|
||||
}
|
||||
lo--
|
||||
}
|
||||
}
|
||||
value := lo + (hi-lo)/2
|
||||
if index == 0 {
|
||||
value = min(maxSortablePriority, lo+10)
|
||||
} else if index == len(levels) {
|
||||
value = max(-maxSortablePriority, hi-10)
|
||||
}
|
||||
values[item.ID] = value
|
||||
}
|
||||
for _, entry := range items {
|
||||
value := values[entry.ID]
|
||||
if value != entry.value() {
|
||||
changes = append(changes, ChannelPriorityChange{ID: entry.ID, Previous: entry.Priority, Priority: &value})
|
||||
}
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func equalPriority(a, b *int64) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestChannelPriorityPlan(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
values []int64
|
||||
id, target int
|
||||
position string
|
||||
want map[int]int64
|
||||
}{
|
||||
{"midpoint", []int64{100, 80, 0}, 3, 2, "before", map[int]int64{3: 90}},
|
||||
{"top preserves peers", []int64{0, 0, 0}, 3, 1, "before", map[int]int64{3: 10}},
|
||||
{"bottom", []int64{0, 0, 0}, 1, 3, "after", map[int]int64{1: -10}},
|
||||
{"same tier", []int64{100, 80, 0}, 3, 2, "same", map[int]int64{3: 80}},
|
||||
{"same slot", []int64{100, 80, 0}, 2, 1, "after", map[int]int64{}},
|
||||
{"same tier no op", []int64{0, 0}, 1, 2, "same", map[int]int64{}},
|
||||
{"shift smaller upper tier", []int64{100, 99, 99, 0}, 4, 2, "before", map[int]int64{1: 101, 4: 100}},
|
||||
{"shift smaller lower tier", []int64{100, 100, 99, 0}, 4, 3, "before", map[int]int64{3: 98, 4: 99}},
|
||||
{"shift complete adjacent tiers", []int64{102, 101, 100, 99, 0}, 5, 3, "after", map[int]int64{4: 98, 5: 99}},
|
||||
{"upper safe bound", []int64{maxSortablePriority, 0}, 2, 1, "before", map[int]int64{1: maxSortablePriority - 1, 2: maxSortablePriority}},
|
||||
{"lower safe bound", []int64{-maxSortablePriority, 0}, 2, 1, "after", map[int]int64{1: -maxSortablePriority + 1, 2: -maxSortablePriority}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
items := make([]ChannelPriority, len(tc.values))
|
||||
for i, value := range tc.values {
|
||||
items[i] = ChannelPriority{ID: i + 1, Priority: &value}
|
||||
}
|
||||
changes, err := planChannelPriorityMove(items, ChannelPriorityMove{ID: tc.id, TargetID: tc.target, Position: tc.position})
|
||||
require.NoError(t, err)
|
||||
got := map[int]int64{}
|
||||
for _, change := range changes {
|
||||
require.NotNil(t, change.Priority)
|
||||
got[change.ID] = *change.Priority
|
||||
}
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
_, err := planChannelPriorityMove([]ChannelPriority{{ID: 1}, {ID: 2}}, ChannelPriorityMove{ID: 1, TargetID: 2, Position: "invalid"})
|
||||
assert.ErrorIs(t, err, ErrChannelPriorityInvalid)
|
||||
}
|
||||
|
||||
func TestChannelPriorityDatabaseMatrix(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
var driver gorm.Dialector
|
||||
switch dialect {
|
||||
case "sqlite":
|
||||
driver = sqlite.Open(":memory:")
|
||||
case "mysql":
|
||||
dsn := os.Getenv("TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_MYSQL_DSN not configured")
|
||||
}
|
||||
driver = mysql.Open(dsn)
|
||||
case "postgres":
|
||||
dsn := os.Getenv("TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_POSTGRES_DSN not configured")
|
||||
}
|
||||
driver = postgres.Open(dsn)
|
||||
}
|
||||
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "priority_test_"}})
|
||||
require.NoError(t, err)
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
previousDB, previousType := DB, common.MainDatabaseType()
|
||||
DB = db
|
||||
common.SetMainDatabaseType(common.DatabaseType(dialect))
|
||||
initCol()
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, db.Migrator().DropTable(&Ability{}, &Channel{}))
|
||||
DB = previousDB
|
||||
common.SetMainDatabaseType(previousType)
|
||||
initCol()
|
||||
require.NoError(t, sqlDB.Close())
|
||||
})
|
||||
require.NoError(t, db.AutoMigrate(&Channel{}, &Ability{}))
|
||||
var version string
|
||||
query := "SELECT version()"
|
||||
if dialect == "sqlite" {
|
||||
query = "SELECT sqlite_version()"
|
||||
}
|
||||
require.NoError(t, db.Raw(query).Scan(&version).Error)
|
||||
t.Logf("database version: %s", version)
|
||||
high, low, zero := int64(100), int64(99), int64(0)
|
||||
w1, w2, w3, w4 := uint(11), uint(22), uint(33), uint(44)
|
||||
channels := []Channel{
|
||||
{Name: "high", Priority: &high, Weight: &w1, Key: "preserved", Status: 1},
|
||||
{Name: "low", Priority: &low, Weight: &w2, Status: 1},
|
||||
{Name: "peer", Priority: &low, Weight: &w3, Status: 1},
|
||||
{Name: "moving", Priority: &zero, Weight: &w4, Status: 1},
|
||||
}
|
||||
require.NoError(t, db.Create(&channels).Error)
|
||||
for _, channel := range channels {
|
||||
for _, group := range []string{"default", "second"} {
|
||||
require.NoError(t, db.Create(&Ability{Group: group, Model: "test", ChannelId: channel.Id, Priority: channel.Priority, Weight: uint(*channel.Weight), Enabled: true}).Error)
|
||||
}
|
||||
}
|
||||
initial, err := GetChannelPriorityOrder()
|
||||
require.NoError(t, err)
|
||||
move := ChannelPriorityMove{Revision: initial.Revision, ID: channels[3].Id, TargetID: channels[1].Id, Position: "before", Preview: true}
|
||||
preview, err := ReorderChannelPriority(move)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, preview.Changes, 2)
|
||||
unchanged, err := GetChannelPriorityOrder()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, initial, unchanged, "preview does not persist")
|
||||
move.Preview = false
|
||||
applied, err := ReorderChannelPriority(move)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, preview, applied)
|
||||
for _, change := range applied.Changes {
|
||||
var abilities []Ability
|
||||
require.NoError(t, db.Where("channel_id = ?", change.ID).Find(&abilities).Error)
|
||||
require.Len(t, abilities, 2)
|
||||
for _, ability := range abilities {
|
||||
assert.Equal(t, change.Priority, ability.Priority)
|
||||
assert.True(t, ability.Enabled)
|
||||
}
|
||||
}
|
||||
var saved Channel
|
||||
require.NoError(t, db.First(&saved, channels[0].Id).Error)
|
||||
assert.Equal(t, "preserved", saved.Key)
|
||||
assert.Equal(t, channels[0].Weight, saved.Weight)
|
||||
_, err = ReorderChannelPriority(move)
|
||||
assert.ErrorIs(t, err, ErrChannelPriorityConflict)
|
||||
undo := ChannelPriorityMove{Revision: applied.Revision}
|
||||
for _, change := range applied.Changes {
|
||||
undo.Restore = append(undo.Restore, ChannelPriorityChange{ID: change.ID, Previous: change.Priority, Priority: change.Previous})
|
||||
}
|
||||
restored, err := ReorderChannelPriority(undo)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, initial.Items, restored.Items)
|
||||
assert.Equal(t, initial.Revision, restored.Revision)
|
||||
if dialect != "sqlite" {
|
||||
sqlDB.SetMaxOpenConns(2)
|
||||
}
|
||||
start := make(chan struct{})
|
||||
errorsCh := make(chan error, 2)
|
||||
var workers sync.WaitGroup
|
||||
for range 2 {
|
||||
workers.Go(func() { <-start; _, moveErr := ReorderChannelPriority(move); errorsCh <- moveErr })
|
||||
}
|
||||
close(start)
|
||||
workers.Wait()
|
||||
close(errorsCh)
|
||||
succeeded, conflicted := 0, 0
|
||||
for moveErr := range errorsCh {
|
||||
if moveErr == nil {
|
||||
succeeded++
|
||||
} else {
|
||||
require.ErrorIs(t, moveErr, ErrChannelPriorityConflict)
|
||||
conflicted++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, succeeded)
|
||||
assert.Equal(t, 1, conflicted)
|
||||
_, err = ReorderChannelPriority(undo)
|
||||
require.NoError(t, err)
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
// An ability write failure must roll back every channel change too.
|
||||
require.NoError(t, db.Callback().Update().Before("gorm:update").Register("priority_test_failure", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "priority_test_abilities" {
|
||||
tx.AddError(errors.New("forced routing write failure"))
|
||||
}
|
||||
}))
|
||||
_, err = ReorderChannelPriority(move)
|
||||
require.ErrorContains(t, err, "forced routing write failure")
|
||||
require.NoError(t, db.Callback().Update().Remove("priority_test_failure"))
|
||||
unchanged, err = GetChannelPriorityOrder()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, initial, unchanged)
|
||||
// Null priority is an effective zero and undo must restore the null itself.
|
||||
require.NoError(t, db.Model(&Channel{}).Where("id = ?", channels[3].Id).Update("priority", nil).Error)
|
||||
nullable, err := GetChannelPriorityOrder()
|
||||
require.NoError(t, err)
|
||||
var sorted []ChannelPriority
|
||||
require.NoError(t, NewChannelSortOptions("priority", "asc", false).Apply(db.Model(&Channel{}).Select("id", "priority")).Find(&sorted).Error)
|
||||
assert.Equal(t, []int{channels[3].Id, channels[2].Id, channels[1].Id, channels[0].Id}, []int{sorted[0].ID, sorted[1].ID, sorted[2].ID, sorted[3].ID})
|
||||
move.Revision = nullable.Revision
|
||||
applied, err = ReorderChannelPriority(move)
|
||||
require.NoError(t, err)
|
||||
undo = ChannelPriorityMove{Revision: applied.Revision}
|
||||
for _, change := range applied.Changes {
|
||||
undo.Restore = append(undo.Restore, ChannelPriorityChange{ID: change.ID, Previous: change.Priority, Priority: change.Previous})
|
||||
}
|
||||
restored, err = ReorderChannelPriority(undo)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, nullable.Items, restored.Items)
|
||||
})
|
||||
}
|
||||
}
|
||||
+19
-10
@@ -386,17 +386,26 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
|
||||
logger.LogError(c, common.LogText("failed to record log: %s", err.Error()))
|
||||
}
|
||||
if common.DataExportEnabled {
|
||||
var cacheReadTokens, cacheCreationTokens int
|
||||
if other != nil {
|
||||
cacheReadTokens, _ = other.public["cache_tokens"].(int)
|
||||
// The settlement path already normalizes the cache creation total.
|
||||
// Do not add the 5m/1h breakdown again or infer tokens from prices.
|
||||
cacheCreationTokens, _ = other.public["cache_write_tokens"].(int)
|
||||
}
|
||||
LogQuotaData(QuotaDataLogParams{
|
||||
UserID: userId,
|
||||
Username: username,
|
||||
ModelName: params.ModelName,
|
||||
Quota: params.Quota,
|
||||
CreatedAt: createdAt,
|
||||
TokenUsed: params.PromptTokens + params.CompletionTokens,
|
||||
UseGroup: params.Group,
|
||||
TokenID: params.TokenId,
|
||||
ChannelID: params.ChannelId,
|
||||
NodeName: common.NodeName,
|
||||
UserID: userId,
|
||||
Username: username,
|
||||
ModelName: params.ModelName,
|
||||
Quota: params.Quota,
|
||||
CreatedAt: createdAt,
|
||||
TokenUsed: params.PromptTokens + params.CompletionTokens,
|
||||
CacheReadTokens: int64(cacheReadTokens),
|
||||
CacheCreationTokens: int64(cacheCreationTokens),
|
||||
UseGroup: params.Group,
|
||||
TokenID: params.TokenId,
|
||||
ChannelID: params.ChannelId,
|
||||
NodeName: common.NodeName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ func GetModelPricingSnapshot(names []string) (*ModelPricingSnapshot, error) {
|
||||
}
|
||||
plugins := generation.PluginsByModel(name)
|
||||
configuredVariants, _ := configured[billing_setting.PluginBillingExprOption].(map[string]any)
|
||||
if len(plugins) >= 2 || len(configuredVariants) > 0 {
|
||||
if len(plugins) > 0 || len(configuredVariants) > 0 {
|
||||
keys := make(map[string]bool, len(plugins)+len(configuredVariants))
|
||||
for _, plugin := range plugins {
|
||||
keys[plugin.Meta.Key] = true
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
@@ -126,16 +127,21 @@ const (
|
||||
CacheWriteNone CacheWriteMode = "none"
|
||||
CacheWriteStandard CacheWriteMode = "standard"
|
||||
CacheWriteClaudeTTL CacheWriteMode = "claude_ttl"
|
||||
CacheWriteOpenAITTL CacheWriteMode = "openai_ttl"
|
||||
)
|
||||
|
||||
// ResolveCacheWriteMode describes display and migration only. A generic engine
|
||||
// fallback is not configured cache-write pricing. Only Claude names inherit the
|
||||
// legacy dual-TTL rule; other models retain their configured write price.
|
||||
// legacy dual-TTL rule; a configured write price on a GPT name also prices the
|
||||
// OpenAI 12h write, and other models retain their configured write price.
|
||||
func ResolveCacheWriteMode(name string, configured PricingValues) CacheWriteMode {
|
||||
if strings.Contains(strings.ToLower(name), "claude") {
|
||||
return CacheWriteClaudeTTL
|
||||
}
|
||||
if _, exists := configured["CreateCacheRatio"]; exists {
|
||||
if strings.Contains(strings.ToLower(name), "gpt") {
|
||||
return CacheWriteOpenAITTL
|
||||
}
|
||||
return CacheWriteStandard
|
||||
}
|
||||
return CacheWriteNone
|
||||
@@ -342,8 +348,9 @@ func PreviewModelPricingConversion(name string, draft PricingValues) (*ModelPric
|
||||
var expressionBody strings.Builder
|
||||
expressionBody.WriteString(`tier("base", p * `)
|
||||
expressionBody.WriteString(base.String())
|
||||
// Retain explicit zero coefficients for included categories. Only
|
||||
// Claude names inherit the relay's 1h / 5m factor of 6 / 3.75.
|
||||
// Retain explicit zero coefficients for included categories. Claude
|
||||
// names inherit the relay's 1h / 5m factor and GPT names its 12h / 30m
|
||||
// factor.
|
||||
for _, lane := range []struct {
|
||||
variable, key string
|
||||
multiplier float64
|
||||
@@ -351,14 +358,16 @@ func PreviewModelPricingConversion(name string, draft PricingValues) (*ModelPric
|
||||
{"c", "CompletionRatio", 1},
|
||||
{"cr", "CacheRatio", 1},
|
||||
{"cc", "CreateCacheRatio", 1},
|
||||
{"cc1h", "CreateCacheRatio", 6 / 3.75},
|
||||
{"cc1h", "CreateCacheRatio", types.ClaudeCacheCreation1hMultiplier},
|
||||
{"cc12h", "CreateCacheRatio", types.OpenAICacheCreation12hMultiplier},
|
||||
{"img", "ImageRatio", 1},
|
||||
} {
|
||||
if ordinaryAudio && !preview.BillingDetails.AudioTextBranches && lane.variable != "c" {
|
||||
continue
|
||||
}
|
||||
if lane.variable == "cc" && preview.CacheWriteMode == CacheWriteNone ||
|
||||
lane.variable == "cc1h" && preview.CacheWriteMode != CacheWriteClaudeTTL {
|
||||
lane.variable == "cc1h" && preview.CacheWriteMode != CacheWriteClaudeTTL ||
|
||||
lane.variable == "cc12h" && preview.CacheWriteMode != CacheWriteOpenAITTL {
|
||||
continue
|
||||
}
|
||||
multiplier := effective[lane.key].(float64)
|
||||
|
||||
@@ -66,6 +66,9 @@ func InitOptionMap() {
|
||||
common.OptionMap["EmailAliasRestrictionEnabled"] = strconv.FormatBool(common.EmailAliasRestrictionEnabled)
|
||||
common.OptionMap["EmailDomainWhitelist"] = strings.Join(common.EmailDomainWhitelist, ",")
|
||||
common.OptionMap["SMTPServer"] = ""
|
||||
common.OptionMap["EmailProvider"] = "smtp"
|
||||
common.OptionMap["ResendApiKey"] = ""
|
||||
common.OptionMap["ResendFrom"] = ""
|
||||
common.OptionMap["SMTPFrom"] = ""
|
||||
common.OptionMap["SMTPPort"] = strconv.Itoa(common.SMTPPort)
|
||||
common.OptionMap["SMTPAccount"] = ""
|
||||
@@ -143,6 +146,7 @@ func InitOptionMap() {
|
||||
common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser)
|
||||
common.OptionMap["QuotaForInviter"] = strconv.Itoa(common.QuotaForInviter)
|
||||
common.OptionMap["QuotaForInvitee"] = strconv.Itoa(common.QuotaForInvitee)
|
||||
common.OptionMap["QuotaRemindEnabled"] = strconv.FormatBool(common.QuotaRemindEnabled)
|
||||
common.OptionMap["QuotaRemindThreshold"] = strconv.Itoa(common.QuotaRemindThreshold)
|
||||
common.OptionMap["PreConsumedQuota"] = strconv.Itoa(common.PreConsumedQuota)
|
||||
common.OptionMap["ModelRequestRateLimitCount"] = strconv.Itoa(setting.ModelRequestRateLimitCount)
|
||||
@@ -234,6 +238,27 @@ func validateOptionValue(key string, value string) error {
|
||||
if err := operation_setting.ValidateQuotaOption(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
if key == "EmailProvider" || key == "ResendApiKey" || key == "ResendFrom" {
|
||||
if key == "EmailProvider" && value != "smtp" && value != "resend" {
|
||||
return common.NewMessage("Email sending method must be SMTP or Resend")
|
||||
}
|
||||
common.OptionMapRWMutex.RLock()
|
||||
settings := map[string]string{
|
||||
"EmailProvider": common.OptionMap["EmailProvider"],
|
||||
"ResendApiKey": common.OptionMap["ResendApiKey"],
|
||||
"ResendFrom": common.OptionMap["ResendFrom"],
|
||||
}
|
||||
common.OptionMapRWMutex.RUnlock()
|
||||
settings[key] = value
|
||||
// While Resend is the active method, a change must keep settings it can
|
||||
// send with. Inactive drafts are checked when Resend is turned on.
|
||||
if settings["EmailProvider"] == "resend" {
|
||||
return common.ValidateResendConfiguration(settings["ResendApiKey"], settings["ResendFrom"])
|
||||
}
|
||||
}
|
||||
if key == "ResendKeyConfigured" {
|
||||
return common.NewMessage("Resend key configuration status is read-only")
|
||||
}
|
||||
if key == operation_setting.ToolPriceOptionKey {
|
||||
return operation_setting.ValidateToolPricesJSON(value)
|
||||
}
|
||||
@@ -404,6 +429,8 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
common.AutomaticDisableChannelEnabled = boolValue
|
||||
case "AutomaticEnableChannelEnabled":
|
||||
common.AutomaticEnableChannelEnabled = boolValue
|
||||
case "QuotaRemindEnabled":
|
||||
common.QuotaRemindEnabled = boolValue
|
||||
case "LogConsumeEnabled":
|
||||
common.LogConsumeEnabled = boolValue
|
||||
case "DisplayInCurrencyEnabled":
|
||||
|
||||
@@ -117,3 +117,23 @@ func TestNewAPIChannelServesExtendedTaskPlugins(t *testing.T) {
|
||||
require.NotNil(t, selected)
|
||||
assert.Equal(t, "gateway", selected.Name, "requests without a pinned plugin keep using the gateway")
|
||||
}
|
||||
|
||||
func TestModelModifierNamesRouteWithAndWithoutMemoryCache(t *testing.T) {
|
||||
truncateTables(t)
|
||||
originalMemoryCache := common.MemoryCacheEnabled
|
||||
t.Cleanup(func() { common.MemoryCacheEnabled = originalMemoryCache; InitChannelCache() })
|
||||
channel := Channel{Id: 930001, Type: constant.ChannelTypeDeepSeek, Status: common.ChannelStatusEnabled, Name: "deepseek", Models: "deepseek-flash", Group: "default"}
|
||||
require.NoError(t, channel.Insert())
|
||||
|
||||
for _, memoryCache := range []bool{false, true} {
|
||||
common.MemoryCacheEnabled = memoryCache
|
||||
InitChannelCache()
|
||||
selected, err := GetRandomSatisfiedChannel("default", "deepseek-flash@effort:max", 0, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, selected, "memory cache %v", memoryCache)
|
||||
assert.Equal(t, "deepseek", selected.Name)
|
||||
selected, err = GetRandomSatisfiedChannel("default", "deepseek-pro@effort:max", 0, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, selected, "memory cache %v", memoryCache)
|
||||
}
|
||||
}
|
||||
+50
-40
@@ -11,31 +11,35 @@ import (
|
||||
|
||||
// QuotaData 柱状图数据
|
||||
type QuotaData struct {
|
||||
Id int `json:"id"`
|
||||
UserID int `json:"user_id" gorm:"index"`
|
||||
Username string `json:"username" gorm:"index:idx_qdt_model_user_name,priority:2;size:64;default:''"`
|
||||
ModelName string `json:"model_name" gorm:"index:idx_qdt_model_user_name,priority:1;size:64;default:''"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_qdt_created_at,priority:2"`
|
||||
UseGroup string `json:"use_group" gorm:"index;size:64;default:''"`
|
||||
TokenID int `json:"token_id" gorm:"index;default:0"`
|
||||
ChannelID int `json:"channel_id" gorm:"index;default:0"`
|
||||
NodeName string `json:"node_name" gorm:"index;size:64;default:''"`
|
||||
TokenUsed int `json:"token_used" gorm:"default:0"`
|
||||
Count int `json:"count" gorm:"default:0"`
|
||||
Quota int `json:"quota" gorm:"default:0"`
|
||||
Id int `json:"id"`
|
||||
UserID int `json:"user_id" gorm:"index"`
|
||||
Username string `json:"username" gorm:"index:idx_qdt_model_user_name,priority:2;size:64;default:''"`
|
||||
ModelName string `json:"model_name" gorm:"index:idx_qdt_model_user_name,priority:1;size:64;default:''"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_qdt_created_at,priority:2"`
|
||||
UseGroup string `json:"use_group" gorm:"index;size:64;default:''"`
|
||||
TokenID int `json:"token_id" gorm:"index;default:0"`
|
||||
ChannelID int `json:"channel_id" gorm:"index;default:0"`
|
||||
NodeName string `json:"node_name" gorm:"index;size:64;default:''"`
|
||||
TokenUsed int `json:"token_used" gorm:"default:0"`
|
||||
Count int `json:"count" gorm:"default:0"`
|
||||
Quota int `json:"quota" gorm:"default:0"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens" gorm:"default:0"`
|
||||
CacheCreationTokens int64 `json:"cache_creation_tokens" gorm:"default:0"`
|
||||
}
|
||||
|
||||
type QuotaDataLogParams struct {
|
||||
UserID int
|
||||
Username string
|
||||
ModelName string
|
||||
Quota int
|
||||
CreatedAt int64
|
||||
TokenUsed int
|
||||
UseGroup string
|
||||
TokenID int
|
||||
ChannelID int
|
||||
NodeName string
|
||||
UserID int
|
||||
Username string
|
||||
ModelName string
|
||||
Quota int
|
||||
CreatedAt int64
|
||||
TokenUsed int
|
||||
CacheReadTokens int64
|
||||
CacheCreationTokens int64
|
||||
UseGroup string
|
||||
TokenID int
|
||||
ChannelID int
|
||||
NodeName string
|
||||
}
|
||||
|
||||
func UpdateQuotaData() {
|
||||
@@ -70,6 +74,8 @@ func logQuotaDataCache(quotaData *QuotaData) {
|
||||
cachedQuotaData.Count += count
|
||||
cachedQuotaData.Quota += quota
|
||||
cachedQuotaData.TokenUsed += tokenUsed
|
||||
cachedQuotaData.CacheReadTokens += quotaData.CacheReadTokens
|
||||
cachedQuotaData.CacheCreationTokens += quotaData.CacheCreationTokens
|
||||
quotaData = cachedQuotaData
|
||||
}
|
||||
CacheQuotaData[key] = quotaData
|
||||
@@ -79,17 +85,19 @@ func LogQuotaData(params QuotaDataLogParams) {
|
||||
// 只精确到小时
|
||||
createdAt := params.CreatedAt - (params.CreatedAt % 3600)
|
||||
quotaData := &QuotaData{
|
||||
UserID: params.UserID,
|
||||
Username: params.Username,
|
||||
ModelName: params.ModelName,
|
||||
CreatedAt: createdAt,
|
||||
UseGroup: params.UseGroup,
|
||||
TokenID: params.TokenID,
|
||||
ChannelID: params.ChannelID,
|
||||
NodeName: params.NodeName,
|
||||
Count: 1,
|
||||
Quota: params.Quota,
|
||||
TokenUsed: params.TokenUsed,
|
||||
UserID: params.UserID,
|
||||
Username: params.Username,
|
||||
ModelName: params.ModelName,
|
||||
CreatedAt: createdAt,
|
||||
UseGroup: params.UseGroup,
|
||||
TokenID: params.TokenID,
|
||||
ChannelID: params.ChannelID,
|
||||
NodeName: params.NodeName,
|
||||
Count: 1,
|
||||
Quota: params.Quota,
|
||||
TokenUsed: params.TokenUsed,
|
||||
CacheReadTokens: max(0, params.CacheReadTokens),
|
||||
CacheCreationTokens: max(0, params.CacheCreationTokens),
|
||||
}
|
||||
|
||||
CacheQuotaDataLock.Lock()
|
||||
@@ -129,9 +137,11 @@ func increaseQuotaData(quotaData *QuotaData) {
|
||||
Where("user_id = ? and username = ? and model_name = ? and created_at = ? and use_group = ? and token_id = ? and channel_id = ? and node_name = ?",
|
||||
quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt, quotaData.UseGroup, quotaData.TokenID, quotaData.ChannelID, quotaData.NodeName).
|
||||
Updates(map[string]any{
|
||||
"count": gorm.Expr("count + ?", quotaData.Count),
|
||||
"quota": gorm.Expr("quota + ?", quotaData.Quota),
|
||||
"token_used": gorm.Expr("token_used + ?", quotaData.TokenUsed),
|
||||
"count": gorm.Expr("count + ?", quotaData.Count),
|
||||
"quota": gorm.Expr("quota + ?", quotaData.Quota),
|
||||
"token_used": gorm.Expr("token_used + ?", quotaData.TokenUsed),
|
||||
"cache_read_tokens": gorm.Expr("cache_read_tokens + ?", quotaData.CacheReadTokens),
|
||||
"cache_creation_tokens": gorm.Expr("cache_creation_tokens + ?", quotaData.CacheCreationTokens),
|
||||
}).Error
|
||||
if err != nil {
|
||||
common.SysLog(common.LogText("increaseQuotaData error: %s", err))
|
||||
@@ -142,7 +152,7 @@ func GetQuotaDataByUsername(username string, startTime int64, endTime int64) (qu
|
||||
var quotaDatas []*QuotaData
|
||||
// 从quota_data表中查询数据
|
||||
err = DB.Table("quota_data").
|
||||
Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
|
||||
Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, sum(cache_read_tokens) as cache_read_tokens, sum(cache_creation_tokens) as cache_creation_tokens").
|
||||
Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime).
|
||||
Group("user_id, username, model_name, created_at").
|
||||
Find("aDatas).Error
|
||||
@@ -153,7 +163,7 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData
|
||||
var quotaDatas []*QuotaData
|
||||
// 从quota_data表中查询数据
|
||||
err = DB.Table("quota_data").
|
||||
Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
|
||||
Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, sum(cache_read_tokens) as cache_read_tokens, sum(cache_creation_tokens) as cache_creation_tokens").
|
||||
Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime).
|
||||
Group("user_id, username, model_name, created_at").
|
||||
Find("aDatas).Error
|
||||
@@ -163,7 +173,7 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData
|
||||
func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
|
||||
var quotaDatas []*QuotaData
|
||||
err = DB.Table("quota_data").
|
||||
Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
|
||||
Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, sum(cache_read_tokens) as cache_read_tokens, sum(cache_creation_tokens) as cache_creation_tokens").
|
||||
Where("created_at >= ? and created_at <= ?", startTime, endTime).
|
||||
Group("username, created_at").
|
||||
Find("aDatas).Error
|
||||
@@ -178,6 +188,6 @@ func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaDat
|
||||
// 从quota_data表中查询数据
|
||||
// only select model_name, sum(count) as count, sum(quota) as quota, model_name, created_at from quota_data group by model_name, created_at;
|
||||
//err = DB.Table("quota_data").Where("created_at >= ? and created_at <= ?", startTime, endTime).Find("aDatas).Error
|
||||
err = DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime).Group("model_name, created_at").Find("aDatas).Error
|
||||
err = DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, sum(cache_read_tokens) as cache_read_tokens, sum(cache_creation_tokens) as cache_creation_tokens, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime).Group("model_name, created_at").Find("aDatas).Error
|
||||
return quotaDatas, err
|
||||
}
|
||||
@@ -1425,11 +1425,6 @@ func DeltaUpdateUserQuota(id int, delta int) (err error) {
|
||||
// return email
|
||||
//}
|
||||
|
||||
func GetRootUser() (user *User) {
|
||||
DB.Where("role = ?", common.RoleRootUser).First(&user)
|
||||
return user
|
||||
}
|
||||
|
||||
func UpdateUserLastLoginAt(id int) {
|
||||
if err := DB.Model(&User{}).Where("id = ?", id).Update("last_login_at", common.GetTimestamp()).Error; err != nil {
|
||||
common.SysLog(common.LogText("failed to update user last_login_at: %s", err.Error()))
|
||||
|
||||
@@ -132,6 +132,7 @@ var compileEnvPrototypeV1 = map[string]any{
|
||||
"cr": float64(0),
|
||||
"cc": float64(0),
|
||||
"cc1h": float64(0),
|
||||
"cc12h": float64(0),
|
||||
"img": float64(0),
|
||||
"img_cr": float64(0),
|
||||
"img_o": float64(0),
|
||||
|
||||
+13
-8
@@ -10,7 +10,7 @@ The expression is the billing contract between the administrator and the system.
|
||||
|
||||
1. **Expression is self-contained** — The expression string alone determines billing. No external ratio tables, no implicit completion multipliers, no hidden conversion factors. Given the same token counts and request context, the same expression always produces the same cost.
|
||||
|
||||
2. **Variables are opt-in** — `p` (prompt) and `c` (completion) are the base. Cache (`cr`, `cc`, `cc1h`), image (`img`), and audio (`ai`, `ao`) variables are optional. If omitted, those tokens are included in `p`/`c` and priced at their rate. The system automatically detects which variables the expression uses (via AST introspection) and adjusts token normalization accordingly.
|
||||
2. **Variables are opt-in** — `p` (prompt) and `c` (completion) are the base. Cache (`cr`, `cc`, `cc1h`, `cc12h`), image (`img`), and audio (`ai`, `ao`) variables are optional. If omitted, those tokens are included in `p`/`c` and priced at their rate; an omitted long-lived cache write keeps its fixed multiple of the short write price. The system automatically detects which variables the expression uses (via AST introspection) and adjusts token normalization accordingly.
|
||||
|
||||
3. **Prices are real prices** — Token coefficients are actual $/1M tokens prices as published by providers. `p * 2.5` means $2.50 per 1M prompt tokens; `fixed(0.01)` means $0.01 per request. No ratio conversion or `/2` convention is required.
|
||||
|
||||
@@ -33,8 +33,9 @@ Powered by [expr-lang/expr](https://github.com/expr-lang/expr). Expressions are
|
||||
| `p` | 输入 token 数(**计价用**)。**自动排除**表达式中单独计价的子类别(见下方说明) |
|
||||
| `len` | 输入上下文总长度(**条件判断用**)。不受自动排除影响,始终反映完整输入长度。非 Claude:等于原始 `prompt_tokens`;Claude:等于文本输入 + 缓存读取 + 缓存创建 |
|
||||
| `cr` | 缓存命中(读取)token 数 |
|
||||
| `cc` | 缓存创建 token 数(Claude 5分钟 TTL / 通用) |
|
||||
| `cc` | 缓存创建 token 数(Claude 5分钟 TTL / OpenAI 30分钟 TTL / 通用) |
|
||||
| `cc1h` | 缓存创建 token 数 — 1小时 TTL(Claude 专用) |
|
||||
| `cc12h` | 缓存创建 token 数 — 12小时 TTL(OpenAI 专用) |
|
||||
| `img` | 图片输入 token 数 |
|
||||
| `img_cr` | 图片缓存读取 token 数;只有表达式显式引用且上游提供有效缓存模态明细时才单独拆分 |
|
||||
| `ai` | 音频输入 token 数 |
|
||||
@@ -70,6 +71,9 @@ Powered by [expr-lang/expr](https://github.com/expr-lang/expr). Expressions are
|
||||
| `p * 3 + c * 15` | 500 | 没用 `ao`,音频输出包含在 `c` 里按 $15 计费 |
|
||||
| `p * 3 + c * 15 + ao * 50` | 400 | 用了 `ao`,音频 100 从 `c` 中扣除按 $50 计费 |
|
||||
|
||||
长效缓存写入(Claude `cc1h`、OpenAI `cc12h`)没有单独计价时,按短效写入价格的固定倍数计费:
|
||||
OpenAI 12 小时为 2 倍,Claude 1 小时为 6÷3.75 倍。短效写入价格是 `cc`,`cc` 也未计价时是 `p`。
|
||||
|
||||
#### 图片缓存与兼容性
|
||||
|
||||
OpenAI 兼容图片接口可报告
|
||||
@@ -116,9 +120,10 @@ Anthropic 的独立缓存计数语义不采用这一 OpenAI 图片拆分。
|
||||
省略 `cr` 时,Anthropic 用量中单独报告的缓存读取数会加回 `p`,而 `len` 保持完整上下文长度。
|
||||
缓存创建按对外计费名和原始配置处理:名称包含 `claude`(不区分大小写)时保留 `cc` 和 `cc1h`,
|
||||
其中 1h 价格沿用旧引擎的 `CreateCacheRatio * 6 / 3.75`;其他名称仅在草稿已配置 `CreateCacheRatio`
|
||||
时生成通用 `cc`(包括显式 0),不生成 `cc1h`。没有配置的通用 1.25 倍兜底不产生新计费项。
|
||||
时生成通用 `cc`(包括显式 0),不生成 `cc1h`。这时名称包含 `gpt`(不区分大小写)的模型同时生成 `cc12h`,
|
||||
价格沿用旧引擎的 `CreateCacheRatio * 2`。没有配置的通用 1.25 倍兜底不产生新计费项。
|
||||
不按渠道映射或模型白名单扩展缓存类型,也不要求管理员另选类型。
|
||||
定价快照条目、草稿预览响应和转换结果附带只读 `cache_write_mode`(`none` / `standard` / `claude_ttl`),
|
||||
定价快照条目、草稿预览响应和转换结果附带只读 `cache_write_mode`(`none` / `standard` / `claude_ttl` / `openai_ttl`),
|
||||
用于统一展示。`POST /api/option/model_pricing/preview` 的响应统一为
|
||||
`{success, message, data: {effective, cache_write_mode, billing_details}}`,调用者从 `data.effective`
|
||||
读取生效价格;转换接口和快照条目的 JSON 结构不变。元信息不保存、不参与表达式执行。
|
||||
@@ -156,7 +161,7 @@ OpenAI 已于 2026-05-12 下线 DALL·E 2/3;其校验、默认值和倍率保
|
||||
(Qwen-Image-3.0 为 `usage.output_image_count`)或按各 `choices[].message.content[]` / `results[]`
|
||||
中的图片载荷数结算,任务消费日志同样记录 `image_count`。
|
||||
|
||||
> **注意:** 自动扣除针对 GPT/OpenAI 格式的 API(prompt_tokens 包含子类别)。Claude 格式的 API 不重复扣除缓存;未独立计价的缓存读取加回输入。系统根据上游返回格式自动处理。
|
||||
> **注意:** 自动扣除针对 GPT/OpenAI 格式的 API(prompt_tokens 包含子类别)。Claude 格式的 API 不重复扣除缓存;未独立计价的缓存读取和缓存写入加回输入。系统根据上游返回格式自动处理。
|
||||
|
||||
### Built-in Functions
|
||||
|
||||
@@ -326,8 +331,8 @@ profiles require their own examples, using the same validation as the defaults.
|
||||
|
||||
The host resolves model aliases before selecting usage metadata. Model-level
|
||||
pricing and expression-save APIs use the declared target; an ambiguous alias
|
||||
with multiple targets in the same plugin retains the plugin defaults. Request
|
||||
and usage validation use the executing plugin and final upstream model. Polling
|
||||
with multiple targets in the same plugin retains the plugin defaults. Usage
|
||||
validation uses the executing plugin and final upstream model. Polling
|
||||
selects metadata from each saved task's model, including in mixed-model batches.
|
||||
An upstream name that no profile declares, such as a channel mapping to a vendor
|
||||
endpoint ID, falls back to the client-facing model's profile. Unmatched models
|
||||
@@ -473,7 +478,7 @@ After the upstream response returns with actual token usage:
|
||||
1. `BuildTieredTokenParams(usage, isClaudeUsageSemantic, usedVars)`:
|
||||
- Reads actual token counts from `dto.Usage`
|
||||
- For GPT-format APIs (prompt_tokens includes everything): subtracts sub-categories from P/C **only when** the expression uses their variables (detected via AST introspection of the compiled expression)
|
||||
- For Claude-format APIs: cache reads omitted from the expression are added to input; separately priced cache remains separate
|
||||
- For Claude-format APIs: cache reads and writes omitted from the expression are added to input; separately priced cache remains separate
|
||||
|
||||
2. `TryTieredSettle(relayInfo, params)`:
|
||||
- Uses the captured `BillingSnapshot`, whose group-dependent fields have been refreshed from the final selected group
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// The environment exposes:
|
||||
// - p, c — prompt / completion tokens (auto-excluding separately-priced sub-categories)
|
||||
// - len — total input context length for tier conditions (never reduced by sub-category exclusion)
|
||||
// - cr, cc, cc1h — cache read / creation / creation-1h tokens
|
||||
// - cr, cc, cc1h, cc12h — cache read / creation / creation-1h / creation-12h tokens
|
||||
// - tier(name, value) — trace callback that records which tier matched
|
||||
// - max, min, abs, ceil, floor — standard math helpers
|
||||
//
|
||||
@@ -75,6 +75,7 @@ func runProgram(prog *vm.Program, requestRules []RequestRuleTrace, usedVars map[
|
||||
"cr": params.CR,
|
||||
"cc": params.CC,
|
||||
"cc1h": params.CC1h,
|
||||
"cc12h": params.CC12h,
|
||||
"img": params.Img,
|
||||
"img_cr": params.ImgCR,
|
||||
"img_o": params.ImgO,
|
||||
|
||||
@@ -24,8 +24,9 @@ type TokenParams struct {
|
||||
C float64 // completion tokens (text) — auto-excludes sub-categories priced separately
|
||||
Len float64 // total input context length for tier conditions (non-Claude: raw prompt_tokens; Claude: text + cache read + cache creation)
|
||||
CR float64 // cache read (hit) tokens
|
||||
CC float64 // cache creation tokens (5-min TTL for Claude, generic for others)
|
||||
CC float64 // cache creation tokens (5-min TTL for Claude, 30-min TTL for OpenAI, generic for others)
|
||||
CC1h float64 // cache creation tokens — 1-hour TTL (Claude only)
|
||||
CC12h float64 // cache creation tokens — 12-hour TTL (OpenAI only)
|
||||
Img float64 // image input tokens
|
||||
ImgCR float64 // image cache read tokens, separated only when explicitly priced
|
||||
ImgO float64 // image output tokens
|
||||
|
||||
@@ -132,14 +132,6 @@ func (m Meta) PreservesJSONOrder() bool {
|
||||
return slices.Contains(m.RequiredCapabilities, CapabilityJSONOrder)
|
||||
}
|
||||
|
||||
// AcceptsAutoDuration reports whether the plugin declared duration-auto@1:
|
||||
// request validation lets a duration of -1, the vendor's "the model picks the
|
||||
// length", through to its hooks. The plugin reserves the longest duration the
|
||||
// model can produce and settles the task on the usage the vendor reports.
|
||||
func (m Meta) AcceptsAutoDuration() bool {
|
||||
return slices.Contains(m.RequiredCapabilities, CapabilityDurationAuto)
|
||||
}
|
||||
|
||||
// JSONTextMember names the member of a hook result (a decoded requestBody, a
|
||||
// request descriptor's body) that the host also takes as JSON text, for a
|
||||
// plugin that preserves JSON order; it is empty for other plugins.
|
||||
|
||||
@@ -223,6 +223,9 @@ const (
|
||||
// order, the decoded requestBody as JSON text (json.RawMessage) beside
|
||||
// the Go value in task_request.
|
||||
ContextKeyRequestBodyText = "task_plugin_request_body_text"
|
||||
// ContextKeyRelayEndpoint retains the shared endpoint candidates when an
|
||||
// ordinary channel wins, so task USD expressions cannot become relay prices.
|
||||
ContextKeyRelayEndpoint = "task_plugin_relay_endpoint"
|
||||
)
|
||||
|
||||
type PinnedPlugin struct {
|
||||
|
||||
+33
-17
@@ -199,11 +199,7 @@ func QuerySummaryAll(hours int, groups []string, recentModels ...string) (Summar
|
||||
startTs, endTs := queryWindow(now, hours)
|
||||
recentStartTs, _ := queryWindow(now, recentSuccessHistoryHours)
|
||||
allowedGroups := allowedGroupSet(groups)
|
||||
|
||||
rows, err := model.GetPerfMetricsSummaryBucketsAll(startTs, endTs, groups)
|
||||
if err != nil {
|
||||
return SummaryAllResult{}, err
|
||||
}
|
||||
queryStartTs := startTs
|
||||
|
||||
recentBuckets := make(map[string]map[int64]counters, len(recentModels))
|
||||
for _, name := range recentModels {
|
||||
@@ -219,6 +215,7 @@ func QuerySummaryAll(hours int, groups []string, recentModels ...string) (Summar
|
||||
return SummaryAllResult{}, err
|
||||
}
|
||||
for _, bucket := range buckets {
|
||||
queryStartTs = min(queryStartTs, bucket.BucketTs)
|
||||
mergeModelBucket(recentBuckets, name, bucket.BucketTs, counters{
|
||||
requestCount: bucket.RequestCount,
|
||||
successCount: bucket.SuccessCount,
|
||||
@@ -226,7 +223,11 @@ func QuerySummaryAll(hours int, groups []string, recentModels ...string) (Summar
|
||||
}
|
||||
}
|
||||
|
||||
totals := map[string]counters{}
|
||||
rows, err := model.GetPerfMetricsSummaryBucketsAll(queryStartTs, endTs, groups)
|
||||
if err != nil {
|
||||
return SummaryAllResult{}, err
|
||||
}
|
||||
|
||||
modelBuckets := map[string]map[int64]counters{}
|
||||
for _, row := range rows {
|
||||
value := counters{
|
||||
@@ -236,7 +237,6 @@ func QuerySummaryAll(hours int, groups []string, recentModels ...string) (Summar
|
||||
outputTokens: row.OutputTokens,
|
||||
generationMs: row.GenerationMs,
|
||||
}
|
||||
mergeModelTotals(totals, row.ModelName, value)
|
||||
mergeModelBucket(modelBuckets, row.ModelName, row.BucketTs, value)
|
||||
}
|
||||
|
||||
@@ -257,14 +257,38 @@ func QuerySummaryAll(hours int, groups []string, recentModels ...string) (Summar
|
||||
if _, requested := recentBuckets[k.model]; requested && k.bucketTs >= recentStartTs {
|
||||
mergeModelBucket(recentBuckets, k.model, k.bucketTs, snap)
|
||||
}
|
||||
if k.bucketTs < startTs {
|
||||
if k.bucketTs < queryStartTs {
|
||||
return true
|
||||
}
|
||||
mergeModelTotals(totals, k.model, snap)
|
||||
mergeModelBucket(modelBuckets, k.model, k.bucketTs, snap)
|
||||
return true
|
||||
})
|
||||
|
||||
recentSeries := make(map[string][]SuccessRatePoint, len(recentBuckets))
|
||||
for name, buckets := range recentBuckets {
|
||||
points := recentSuccessSeries(buckets)
|
||||
if len(points) == 0 {
|
||||
continue
|
||||
}
|
||||
recentSeries[name] = points[max(0, len(points)-recentSuccessSampleLimit):]
|
||||
}
|
||||
|
||||
// Requested models are summarized over the same hours as their status samples.
|
||||
totals := map[string]counters{}
|
||||
for name, buckets := range modelBuckets {
|
||||
fromTs := startTs
|
||||
if points, ok := recentSeries[name]; ok {
|
||||
fromTs = points[0].Ts
|
||||
}
|
||||
for bucketTs, value := range buckets {
|
||||
if bucketTs < fromTs {
|
||||
delete(buckets, bucketTs)
|
||||
continue
|
||||
}
|
||||
mergeModelTotals(totals, name, value)
|
||||
}
|
||||
}
|
||||
|
||||
all := counters{}
|
||||
models := make([]ModelSummary, 0, len(totals))
|
||||
for name, total := range totals {
|
||||
@@ -295,14 +319,6 @@ func QuerySummaryAll(hours int, groups []string, recentModels ...string) (Summar
|
||||
return models[i].RequestCount > models[j].RequestCount
|
||||
})
|
||||
|
||||
recentSeries := make(map[string][]SuccessRatePoint, len(recentBuckets))
|
||||
for name, buckets := range recentBuckets {
|
||||
points := recentSuccessSeries(buckets)
|
||||
if len(points) == 0 {
|
||||
continue
|
||||
}
|
||||
recentSeries[name] = points[max(0, len(points)-recentSuccessSampleLimit):]
|
||||
}
|
||||
return SummaryAllResult{
|
||||
Summary: summarize(all), WindowStart: startTs, WindowEnd: endTs,
|
||||
Models: models, RecentSuccessSeries: recentSeries,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -262,7 +263,7 @@ func TestPerformanceAggregationAndFlush(t *testing.T) {
|
||||
assert.Equal(t, 98.04, combined.Summary.SuccessRate)
|
||||
assert.Equal(t, 99.01, combined.Models[0].SuccessRate)
|
||||
|
||||
t.Run("recent history does not change window summaries", func(t *testing.T) {
|
||||
t.Run("recent models are summarized over their status sample hours", func(t *testing.T) {
|
||||
for i := range 26 {
|
||||
require.NoError(t, model.UpsertPerfMetric(&model.PerfMetric{
|
||||
ModelName: "history-model", Group: "a", BucketTs: hour - int64(i+1)*6*3600,
|
||||
@@ -293,9 +294,13 @@ func TestPerformanceAggregationAndFlush(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
history, err := QuerySummaryAll(24, groups, "history-model", "history-idle", "history-hidden", "history-hot", "missing")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, window.Summary, history.Summary)
|
||||
assert.ElementsMatch(t, window.Models, history.Models)
|
||||
assert.Equal(t, window.WindowStart, history.WindowStart)
|
||||
summaries := lo.KeyBy(history.Models, func(m ModelSummary) string { return m.ModelName })
|
||||
assert.Equal(t, int64(124), summaries["history-model"].RequestCount)
|
||||
assert.Equal(t, 99.19, summaries["history-model"].SuccessRate)
|
||||
assert.Equal(t, int64(1), summaries["history-idle"].RequestCount)
|
||||
assert.Equal(t, 0.0, summaries["history-idle"].SuccessRate)
|
||||
assert.NotContains(t, summaries, "history-hidden")
|
||||
points := history.RecentSuccessSeries["history-model"]
|
||||
require.Len(t, points, 24)
|
||||
expected := make([]SuccessRatePoint, 0, 24)
|
||||
|
||||
@@ -323,7 +323,7 @@ func alibabaObject(t *testing.T, value any) map[string]any {
|
||||
return result
|
||||
}
|
||||
|
||||
// Exercise the production host's recursive usage validation as well as the
|
||||
// Exercise the production host's usage validation as well as the
|
||||
// converter, without sending a paid generation request or needing a database.
|
||||
func submitAlibabaRequest(t *testing.T, plugin *jsplugin.LoadedPlugin, upstream string, request map[string]any) (map[string]any, map[string]any, string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -83,8 +83,8 @@ func TestAlibabaWan3(t *testing.T) {
|
||||
require.ErrorContains(t, callErr, "only supported by wan3.0")
|
||||
})
|
||||
|
||||
// /v1/video/generations has no plugin decoder, so -1 reaches the host's
|
||||
// request validation as a vendor parameter; duration-auto@1 lets it through.
|
||||
// /v1/video/generations has no plugin decoder, so -1 reaches the plugin as
|
||||
// a vendor parameter in metadata.
|
||||
t.Run("metadata parameters keep -1 on /v1/video/generations", func(t *testing.T) {
|
||||
body, facts, _ := submitAlibabaRequest(t, plugin, "wan3.0-video", map[string]any{
|
||||
"model": "wan3.0-video", "prompt": "a cat", "metadata": map[string]any{"parameters": map[string]any{"duration": -1}},
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
package plugins_test
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
builtinplugins "github.com/QuantumNous/new-api/plugins"
|
||||
taskplugin "github.com/QuantumNous/new-api/relay/channel/task/jsplugin"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKlingResponsesProtocol(t *testing.T) {
|
||||
testVideoResponsesProtocol(t, videoResponsesTestCase{
|
||||
@@ -28,4 +43,62 @@ func TestKlingResponsesProtocol(t *testing.T) {
|
||||
wantUsageKeys: []string{"units"},
|
||||
wantVendorName: "kling",
|
||||
})
|
||||
|
||||
// Kling bills the duration it sends, and metadata.duration (the whole body
|
||||
// on native routes) overrides the top-level one, so the plugin bounds that
|
||||
// value before the host reserves quota.
|
||||
t.Run("bills the sent duration and rejects out-of-range durations before billing", func(t *testing.T) {
|
||||
source, err := builtinplugins.Source("kling")
|
||||
require.NoError(t, err)
|
||||
plugin, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: "kling"})
|
||||
require.NoError(t, err)
|
||||
submit := func(t *testing.T, decoder []string, body map[string]any) (*taskplugin.TaskAdaptor, *gin.Context, *relaycommon.RelayInfo, *dto.TaskError) {
|
||||
t.Helper()
|
||||
value, err := plugin.Engine.CallPath(t.Context(), decoder[0], decoder[1:], map[string]any{"model": "kling-v1", "body": map[string]any{"kind": "json", "value": body}})
|
||||
require.NoError(t, err)
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://api-singapore.klingai.com", ApiKey: "ak|sk", UpstreamModelName: "kling-v1"},
|
||||
OriginModelName: "kling-v1",
|
||||
TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"},
|
||||
}
|
||||
adaptor := taskplugin.New(plugin)
|
||||
adaptor.Init(info)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
|
||||
c.Set("task_request", alibabaObject(t, value)["requestBody"])
|
||||
return adaptor, c, info, adaptor.ValidateRequestAndSetAction(c, info)
|
||||
}
|
||||
native := []string{"native", "decodeSubmit"}
|
||||
openaiVideo := []string{"protocols", "openai_video", "decodeRequest"}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
decoder []string
|
||||
body map[string]any
|
||||
}{
|
||||
{"native duration above 3600", native, map[string]any{"model_name": "kling-v1", "prompt": "a cat", "duration": 1e12}},
|
||||
{"native fractional duration", native, map[string]any{"model_name": "kling-v1", "prompt": "a cat", "duration": "5.5"}},
|
||||
{"metadata duration above 3600 behind valid seconds", openaiVideo, map[string]any{"model": "kling-v1", "prompt": "a cat", "seconds": "5", "metadata": map[string]any{"duration": 3601}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, _, taskErr := submit(t, tc.decoder, tc.body)
|
||||
require.NotNil(t, taskErr)
|
||||
assert.Equal(t, http.StatusBadRequest, taskErr.StatusCode)
|
||||
assert.ErrorContains(t, taskErr.Error, "at most 3600")
|
||||
})
|
||||
}
|
||||
|
||||
adaptor, c, info, taskErr := submit(t, openaiVideo, map[string]any{"model": "kling-v1", "prompt": "a cat", "seconds": "5", "metadata": map[string]any{"duration": "10"}})
|
||||
require.Nil(t, taskErr)
|
||||
reader, err := adaptor.BuildRequestBody(c, info)
|
||||
require.NoError(t, err)
|
||||
encoded, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
var sent map[string]any
|
||||
require.NoError(t, common.Unmarshal(encoded, &sent))
|
||||
assert.Equal(t, "10", sent["duration"])
|
||||
facts, err := adaptor.ExtractUsageFactsValidated(c, info)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2.0, facts["units"], "kling-v1 std costs 0.2 credits per second")
|
||||
})
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export const meta = {
|
||||
en: "Kuaishou Kling video generation (text-to-video and image-to-video)",
|
||||
zh: "快手可灵视频生成(文生视频、图生视频)",
|
||||
},
|
||||
version: "1.1.0",
|
||||
version: "1.1.1",
|
||||
author: { name: "QuantumNous" },
|
||||
channelTypes: [50],
|
||||
models: ["kling-v1", "kling-v1-6", "kling-v2-master"],
|
||||
@@ -106,8 +106,11 @@ function viaGateway(ctx) {
|
||||
}
|
||||
|
||||
function tokenFor(ctx) {
|
||||
if (viaGateway(ctx)) return ctx.apiKey;
|
||||
const parts = ctx.apiKey.split("|");
|
||||
const apiKey = trimmed(ctx.apiKey);
|
||||
if (!apiKey) throw new Error("api_key is empty");
|
||||
if (viaGateway(ctx)) return apiKey;
|
||||
const parts = apiKey.split("|");
|
||||
if (parts.length === 1) return apiKey;
|
||||
if (parts.length !== 2) throw new Error("invalid api_key, required format is accessKey|secretKey");
|
||||
const now = utils.unixNow();
|
||||
return utils.jwtSignHS256({ iss: parts[0].trim(), exp: now + 1800, nbf: now - 5 }, parts[1].trim());
|
||||
@@ -162,13 +165,14 @@ function validateKlingDuration(value) {
|
||||
return n;
|
||||
}
|
||||
|
||||
// The duration Kling receives and the one billed: metadata.duration (the whole
|
||||
// body on native routes) overrides the top-level duration, which defaults to 5.
|
||||
function outboundDuration(req) {
|
||||
const n = Number(req && req.duration);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
const metadata = (req && req.metadata) || {};
|
||||
const fromMeta = Number(metadata.duration);
|
||||
if (Number.isFinite(fromMeta) && fromMeta > 0) return fromMeta;
|
||||
return 5;
|
||||
let value = metadata.duration;
|
||||
if (value === undefined || value === null) value = req && req.duration;
|
||||
if (value === undefined || value === null) return 5;
|
||||
return validateKlingDuration(value);
|
||||
}
|
||||
|
||||
function outboundMode(req, model) {
|
||||
@@ -241,7 +245,6 @@ export function buildSubmitRequest(ctx) {
|
||||
prompt: req.prompt,
|
||||
image: req.image,
|
||||
mode: outboundMode(req, model),
|
||||
duration: String(req.duration || 5),
|
||||
aspect_ratio: aspectRatio(req.size),
|
||||
model_name: model,
|
||||
model: model,
|
||||
@@ -250,6 +253,7 @@ export function buildSubmitRequest(ctx) {
|
||||
metadata
|
||||
);
|
||||
body.mode = outboundMode({ mode: body.mode, metadata: metadata }, model);
|
||||
body.duration = String(outboundDuration(req));
|
||||
if (body.image) body.image = filePlaceholder(body.image);
|
||||
if (body.image_tail) body.image_tail = filePlaceholder(body.image_tail);
|
||||
if (!body.prompt) delete body.prompt;
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -45,3 +56,111 @@ func TestBuildAlphaSearchRequestBodyNoMappingKeepsRawBytes(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, raw, out)
|
||||
}
|
||||
|
||||
type recordedSettlement struct {
|
||||
settled []int
|
||||
refunded bool
|
||||
}
|
||||
|
||||
func (s *recordedSettlement) Settle(quota int) error {
|
||||
s.settled = append(s.settled, quota)
|
||||
return nil
|
||||
}
|
||||
func (s *recordedSettlement) Refund(*gin.Context) { s.refunded = true }
|
||||
func (*recordedSettlement) NeedsRefund() bool { return false }
|
||||
func (*recordedSettlement) GetPreConsumedQuota() int { return 0 }
|
||||
func (*recordedSettlement) Reserve(int) error { return nil }
|
||||
|
||||
func (*recordedSettlement) ReserveBeforeSubmit(int) error { return nil }
|
||||
|
||||
// /v1/tools/search and /v1/tools/fetch follow /v1/alpha/search: the raw body
|
||||
// reaches /v1/tools/* unchanged and the response returns unchanged. With only
|
||||
// the built-in prices, a response DeepSeek bills (results, page content)
|
||||
// settles one call at the routing name's request price and no tokens, an
|
||||
// empty one releases the reservation, an upstream error keeps its status, and
|
||||
// a channel that cannot relay the endpoint fails retryably without sending
|
||||
// anything.
|
||||
func TestDeepSeekToolsHelper(t *testing.T) {
|
||||
service.InitHttpClient()
|
||||
gin.SetMode(gin.TestMode)
|
||||
batch, logConsume := common.BatchUpdateEnabled, common.LogConsumeEnabled
|
||||
common.BatchUpdateEnabled, common.LogConsumeEnabled = true, false
|
||||
t.Cleanup(func() { common.BatchUpdateEnabled, common.LogConsumeEnabled = batch, logConsume })
|
||||
|
||||
const search, fetch = "/v1/tools/search", "/v1/tools/fetch"
|
||||
const results = `{"results":[{"title":"DeepSeek","url":"https://deepseek.com/","rerank_score":2.1}]}`
|
||||
tests := []struct {
|
||||
name, path, body, response string
|
||||
channelType, status int
|
||||
settled []int
|
||||
refunded bool
|
||||
}{
|
||||
{name: "search with results", path: search, body: `{"query":"deepseek","count":2,"future_field":{"nested":true}}`,
|
||||
response: results, channelType: constant.ChannelTypeDeepSeek, status: http.StatusOK, settled: []int{375}},
|
||||
{name: "search without results", path: search, body: `{"query":"site:none.invalid deepseek"}`,
|
||||
response: `{"results":[]}`, channelType: constant.ChannelTypeDeepSeek, status: http.StatusOK, refunded: true},
|
||||
{name: "fetch with page content", path: fetch, body: `{"url":"https://example.com/"}`,
|
||||
response: `{"title":"Example Domain","content":"# Example Domain"}`, channelType: constant.ChannelTypeDeepSeek, status: http.StatusOK, settled: []int{375}},
|
||||
{name: "fetch failure keeps its status", path: fetch, body: `{"url":"https://example.com/missing"}`,
|
||||
response: `{"error":{"message":"failed to fetch URL","type":"not_found_error","param":null,"code":"invalid_request_error"}}`,
|
||||
channelType: constant.ChannelTypeDeepSeek, status: http.StatusNotFound},
|
||||
{name: "search through a New API channel", path: search, body: `{"query":"deepseek"}`,
|
||||
response: results, channelType: constant.ChannelTypeNewAPI, status: http.StatusOK, settled: []int{375}},
|
||||
{name: "channel without the endpoint", path: search, body: `{"query":"deepseek"}`, channelType: constant.ChannelTypeOpenAI},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotBody []byte
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = io.WriteString(w, tc.response)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
routingName := map[string]string{search: constant.DeepSeekSearchModel, fetch: constant.DeepSeekFetchModel}[tc.path]
|
||||
common.SetContextKey(c, constant.ContextKeyOriginalModel, routingName)
|
||||
common.SetContextKey(c, constant.ContextKeyUsingGroup, "default")
|
||||
common.SetContextKey(c, constant.ContextKeyUserGroup, "default")
|
||||
common.SetContextKey(c, constant.ContextKeyChannelType, tc.channelType)
|
||||
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, upstream.URL)
|
||||
common.SetContextKey(c, constant.ContextKeyChannelKey, "sk-test")
|
||||
|
||||
request, err := helper.GetAndValidateRequest(c, types.RelayFormatDeepSeekTools)
|
||||
require.NoError(t, err)
|
||||
info, err := relaycommon.GenRelayInfo(c, types.RelayFormatDeepSeekTools, request, nil)
|
||||
require.NoError(t, err)
|
||||
info.BillingRequestInput = &billingexpr.RequestInput{Body: []byte(tc.body)}
|
||||
_, err = helper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta())
|
||||
require.NoError(t, err, "the routing name is priced out of the box")
|
||||
settlement := &recordedSettlement{}
|
||||
info.Billing = settlement
|
||||
|
||||
apiErr := DeepSeekToolsHelper(c, info)
|
||||
if tc.status == 0 {
|
||||
require.NotNil(t, apiErr)
|
||||
assert.False(t, types.IsSkipRetryError(apiErr))
|
||||
assert.Empty(t, gotPath)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tc.path, gotPath)
|
||||
assert.Equal(t, tc.body, string(gotBody))
|
||||
assert.Equal(t, tc.settled, settlement.settled)
|
||||
assert.Equal(t, tc.refunded, settlement.refunded)
|
||||
if tc.status != http.StatusOK {
|
||||
require.NotNil(t, apiErr)
|
||||
assert.Equal(t, tc.status, apiErr.StatusCode)
|
||||
return
|
||||
}
|
||||
require.Nil(t, apiErr)
|
||||
assert.Equal(t, tc.status, recorder.Code)
|
||||
assert.Equal(t, tc.response, recorder.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
default:
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeEmbeddings:
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", info.ChannelBaseUrl)
|
||||
if isMultimodalEmbeddingModel(info.UpstreamModelName) {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding", info.ChannelBaseUrl)
|
||||
} else {
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", info.ChannelBaseUrl)
|
||||
}
|
||||
case constant.RelayModeRerank:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl)
|
||||
case constant.RelayModeResponses:
|
||||
@@ -170,6 +174,9 @@ func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dt
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
|
||||
if isMultimodalEmbeddingModel(info.UpstreamModelName) {
|
||||
return convertToMultimodalEmbeddingRequest(request)
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
@@ -200,6 +207,13 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeRerank:
|
||||
err, usage = RerankHandler(c, resp, info)
|
||||
case constant.RelayModeEmbeddings:
|
||||
if isMultimodalEmbeddingModel(info.UpstreamModelName) {
|
||||
err, usage = MultimodalEmbeddingHandler(c, resp, info)
|
||||
} else {
|
||||
adaptor := openai.Adaptor{}
|
||||
usage, err = adaptor.DoResponse(c, resp, info)
|
||||
}
|
||||
default:
|
||||
adaptor := openai.Adaptor{}
|
||||
usage, err = adaptor.DoResponse(c, resp, info)
|
||||
|
||||
@@ -3,8 +3,10 @@ package ali
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -336,3 +338,152 @@ func TestAliResponsesWebSearchUsage(t *testing.T) {
|
||||
assert.Equal(t, map[string]int{aliSearchAgentKey: 2, dto.BuildInToolWebSearch: 0, dto.BuildInToolWebSearchPreview: 0}, counts, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Bailian multimodal embedding models are served only by the native
|
||||
// multimodal-embedding endpoint, which takes input.contents; text embedding
|
||||
// models keep compatible mode and the OpenAI body.
|
||||
func TestEmbeddingRequestByModel(t *testing.T) {
|
||||
const (
|
||||
base = "https://dashscope.invalid"
|
||||
nativePath = "/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding"
|
||||
)
|
||||
tests := []struct {
|
||||
model string
|
||||
request string // OpenAI embedding request fields after model
|
||||
wantPath string
|
||||
wantBody string // "" means a conversion error
|
||||
}{
|
||||
{
|
||||
model: "qwen3-vl-embedding",
|
||||
request: `"input":"a cat"`,
|
||||
wantPath: nativePath,
|
||||
wantBody: `{"model":"qwen3-vl-embedding","input":{"contents":[{"text":"a cat"}]}}`,
|
||||
},
|
||||
{
|
||||
model: "tongyi-embedding-vision-plus-2026-03-06",
|
||||
request: `"input":["a cat","a dog"],"dimensions":1024`,
|
||||
wantPath: nativePath,
|
||||
wantBody: `{"model":"tongyi-embedding-vision-plus-2026-03-06","input":{"contents":[{"text":"a cat"},{"text":"a dog"}]},"parameters":{"dimension":1024}}`,
|
||||
},
|
||||
{
|
||||
model: "multimodal-embedding-v1",
|
||||
request: `"input":[{"type":"text","text":"a cat"},` +
|
||||
`{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}},` +
|
||||
`{"type":"video_url","video_url":{"url":"https://example.com/cat.mp4"}}]`,
|
||||
wantPath: nativePath,
|
||||
wantBody: `{"model":"multimodal-embedding-v1","input":{"contents":[{"text":"a cat"},` +
|
||||
`{"image":"data:image/png;base64,iVBORw0KGgo="},{"video":"https://example.com/cat.mp4"}]}}`,
|
||||
},
|
||||
{
|
||||
model: "qwen3-vl-embedding",
|
||||
request: `"input":[{"type":"image_url","image_url":{"url":""}}]`,
|
||||
wantPath: nativePath,
|
||||
},
|
||||
{
|
||||
model: "text-embedding-v4",
|
||||
request: `"input":"a cat","dimensions":1024`,
|
||||
wantPath: "/compatible-mode/v1/embeddings",
|
||||
wantBody: `{"model":"text-embedding-v4","input":"a cat","dimensions":1024}`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayMode: constant.RelayModeEmbeddings,
|
||||
RelayFormat: types.RelayFormatEmbedding,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: base, UpstreamModelName: tt.model},
|
||||
}
|
||||
url, err := (&Adaptor{}).GetRequestURL(info)
|
||||
require.NoError(t, err, tt.request)
|
||||
assert.Equal(t, base+tt.wantPath, url, tt.request)
|
||||
|
||||
var request dto.EmbeddingRequest
|
||||
require.NoError(t, common.UnmarshalJsonStr(`{"model":"`+tt.model+`",`+tt.request+`}`, &request))
|
||||
converted, err := (&Adaptor{}).ConvertEmbeddingRequest(nil, info, request)
|
||||
if tt.wantBody == "" {
|
||||
assert.Error(t, err, tt.request)
|
||||
continue
|
||||
}
|
||||
require.NoError(t, err, tt.request)
|
||||
wire, err := common.Marshal(converted)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, tt.wantBody, string(wire), tt.request)
|
||||
}
|
||||
}
|
||||
|
||||
// The native response becomes an OpenAI embedding list. prompt_tokens is all
|
||||
// billable input and image_tokens its visual part, priced at the image ratio;
|
||||
// output_tokens is not priced and never billed. A response without usage
|
||||
// bills the request estimate.
|
||||
func TestMultimodalEmbeddingResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
body string
|
||||
estimate int
|
||||
wantData []dto.EmbeddingResponseItem
|
||||
wantUsage *dto.Usage // nil means an upstream error
|
||||
}{
|
||||
{
|
||||
name: "visual tokens reported apart",
|
||||
model: "qwen3-vl-embedding",
|
||||
body: `{"output":{"embeddings":[{"index":0,"embedding":[0.1,-0.2],"type":"text"},{"index":1,"embedding":[0.3,0.4],"type":"image"}]},` +
|
||||
`"usage":{"input_tokens":43,"image_tokens":1247,"total_tokens":1290},"request_id":"r1"}`,
|
||||
wantData: []dto.EmbeddingResponseItem{
|
||||
{Object: "embedding", Index: 0, Embedding: []float64{0.1, -0.2}},
|
||||
{Object: "embedding", Index: 1, Embedding: []float64{0.3, 0.4}},
|
||||
},
|
||||
wantUsage: &dto.Usage{PromptTokens: 1290, TotalTokens: 1290,
|
||||
PromptTokensDetails: dto.InputTokenDetails{TextTokens: 43, ImageTokens: 1247}},
|
||||
},
|
||||
{
|
||||
name: "input_tokens split in input_tokens_details",
|
||||
model: "tongyi-embedding-vision-plus",
|
||||
body: `{"output":{"embeddings":[{"index":0,"embedding":[0.5],"type":"fused"}]},` +
|
||||
`"usage":{"input_tokens":903,"input_tokens_details":{"image_tokens":896,"text_tokens":7},"output_tokens":3,"total_tokens":906},"request_id":"r2"}`,
|
||||
wantData: []dto.EmbeddingResponseItem{{Object: "embedding", Index: 0, Embedding: []float64{0.5}}},
|
||||
wantUsage: &dto.Usage{PromptTokens: 903, TotalTokens: 903,
|
||||
PromptTokensDetails: dto.InputTokenDetails{TextTokens: 7, ImageTokens: 896}},
|
||||
},
|
||||
{
|
||||
name: "no usage bills the estimate",
|
||||
model: "qwen3-vl-embedding",
|
||||
body: `{"output":{"embeddings":[{"index":0,"embedding":[0.5],"type":"text"}]},"request_id":"r3"}`,
|
||||
estimate: 12,
|
||||
wantData: []dto.EmbeddingResponseItem{{Object: "embedding", Index: 0, Embedding: []float64{0.5}}},
|
||||
wantUsage: &dto.Usage{PromptTokens: 12, TotalTokens: 12},
|
||||
},
|
||||
{
|
||||
name: "error code in a 200 body",
|
||||
model: "qwen3-vl-embedding",
|
||||
body: `{"code":"InvalidParameter","message":"The input is invalid.","request_id":"r4"}`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayMode: constant.RelayModeEmbeddings,
|
||||
RelayFormat: types.RelayFormatEmbedding,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: tt.model},
|
||||
}
|
||||
info.SetEstimatePromptTokens(tt.estimate)
|
||||
resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(tt.body))}
|
||||
|
||||
usage, apiErr := (&Adaptor{}).DoResponse(c, resp, info)
|
||||
if tt.wantUsage == nil {
|
||||
require.NotNil(t, apiErr)
|
||||
assert.Equal(t, http.StatusInternalServerError, apiErr.StatusCode)
|
||||
assert.Equal(t, "InvalidParameter", string(apiErr.GetErrorCode()))
|
||||
assert.Empty(t, w.Body.String())
|
||||
return
|
||||
}
|
||||
require.Nil(t, apiErr)
|
||||
assert.Equal(t, tt.wantUsage, usage)
|
||||
|
||||
var got dto.EmbeddingResponse
|
||||
require.NoError(t, common.Unmarshal(w.Body.Bytes(), &got))
|
||||
assert.Equal(t, dto.EmbeddingResponse{Object: "list", Data: tt.wantData, Model: tt.model, Usage: *tt.wantUsage}, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ var ModelList = []string{
|
||||
"qwq-32b",
|
||||
"qwen3-235b-a22b",
|
||||
"text-embedding-v1",
|
||||
"qwen3-vl-embedding",
|
||||
"gte-rerank-v2",
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 335 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user