feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

+3 -3
View File
@@ -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: **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. - 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. - 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.
- 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. - 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`. - 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. - 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.
@@ -18,11 +18,11 @@ Mandatory conventions for quota, pricing, pre-consume, settlement, refund, and u
- Fields parsed into unsigned types (`*uint`) accept huge positive JSON numbers (e.g. `18446744073686646784`, a wrapped negative); a `>= 0` check is not sufficient, an upper bound is mandatory. - Fields parsed into unsigned types (`*uint`) accept huge positive JSON numbers (e.g. `18446744073686646784`, a wrapped negative); a `>= 0` check is not sufficient, an upper bound is mandatory.
- Regression tests for these invariants belong with the boundary they protect (request validators, converter helpers). See `relay/helper/openai_image_request_test.go`, `relay/common/relay_utils_test.go`, and `common/quota_math_test.go` for the expected style. - Regression tests for these invariants belong with the boundary they protect (request validators, converter helpers). See `relay/helper/openai_image_request_test.go`, `relay/common/relay_utils_test.go`, and `common/quota_math_test.go` for the expected style.
**Upstream image quantity settlement:** The billable image count may be lowered or raised from the upstream response only through `RelayInfo.UpdateImageCount`; a zero or out-of-range count leaves the reserved request quantity in place. Derive the count from image payloads, never from JSON structure: **Upstream image quantity settlement:** The billable image count may be lowered or raised from the upstream response only through `RelayInfo.UpdateImageCount` on the HTTP image relay, or through the `image_count` usage fact a task plugin returns on completion; a zero or out-of-range count leaves the reserved request quantity in place. Derive the count from image payloads, never from JSON structure:
- Do NOT count `data` with gjson `data.#`, `len(data)`, or any element count. Providers behind OpenAI-compatible channels return non-standard shapes: `data` as a single object, one image split into a `url` entry and a `b64_json` entry, or entries carrying only `revised_prompt`. - Do NOT count `data` with gjson `data.#`, `len(data)`, or any element count. Providers behind OpenAI-compatible channels return non-standard shapes: `data` as a single object, one image split into a `url` entry and a `b64_json` entry, or entries carrying only `revised_prompt`.
- An object-shaped `data` is one image when it has a non-empty `url` or `b64_json`, otherwise zero. For arrays the count is `max(entries with non-empty url, entries with non-empty b64_json)`: a standard response uses one `response_format` so this equals the entry count, a split image bills once, and payload-less entries bill nothing. The reference implementation is `openaiImageResponseCount` in `relay/channel/openai/relay_image.go`; reuse it (or the same rule) for any channel that forwards or rebuilds OpenAI-format image responses. The xAI adaptor already inherits it through `OpenaiImageHandler`. - An object-shaped `data` is one image when it has a non-empty `url` or `b64_json`, otherwise zero. For arrays the count is `max(entries with non-empty url, entries with non-empty b64_json)`: a standard response uses one `response_format` so this equals the entry count, a split image bills once, and payload-less entries bill nothing. The reference implementation is `openaiImageResponseCount` in `relay/channel/openai/relay_image.go`; reuse it (or the same rule) for any channel that forwards or rebuilds OpenAI-format image responses. The xAI adaptor already inherits it through `OpenaiImageHandler`.
- When an OpenAI-format JSON body is re-emitted as SSE, forward one `image_generation.completed` event per image payload using the same shape handling (object → one event, payload-less entries dropped). The client must never receive only `[DONE]` while being charged for images. - When an OpenAI-format JSON body is re-emitted as SSE, forward one `image_generation.completed` event per image payload using the same shape handling (object → one event, payload-less entries dropped). The client must never receive only `[DONE]` while being charged for images.
- Real SSE streams count `image_generation.completed` / `image_edit.completed` events and only trust a lower count when upstream finished the stream (`done` / `eof`); a client abort must not reduce the charge below the requested quantity. - Real SSE streams count `image_generation.completed` / `image_edit.completed` events and only trust a lower count when upstream finished the stream (`done` / `eof`); a client abort must not reduce the charge below the requested quantity.
- Channels with their own usage field (Ali `usage.image_count`) use that value, bounded by `dto.MaxImageN`, and fall back to the converted response length. Channels that rebuild responses from typed structs count the typed slice; none of them may read `data.#`. - Ali (Bailian) image models are served by the `alibaba` task plugin through the `openai_image` host protocol. Its `imageUsage` reads `usage.image_count`, then `usage.output_image_count` (Qwen-Image-3.0), and otherwise counts image payloads flattened across every `choices[].message.content[]` entry and `results[]`; payload-less parts bill nothing, and a fractional, negative, oversized, or payload-inconsistent upstream count keeps the reserved request quantity. Any other channel with its own usage field applies the same order: trust the bounded upstream count first, then count payloads. Channels that rebuild responses from typed structs count the typed slice; none of them may read `data.#`.
- Regression cases for object, split, and payload-less shapes belong in `relay/channel/openai/image_stream_test.go` (fixed-price, tiered-expression, and JSON-as-SSE forwarding tables). Extend those tables instead of adding new files. - Regression cases for object, split, and payload-less shapes belong in `relay/channel/openai/image_stream_test.go` (fixed-price, tiered-expression, and JSON-as-SSE forwarding tables). Extend those tables instead of adding new files.
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "bun"
directory: "/web"
schedule:
interval: "weekly"
+14 -3
View File
@@ -11,6 +11,8 @@ concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }} group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
# Checkout the event commit in both jobs. A PR merge ref can disappear while
# queued runs start, and a base branch can advance after the merge event.
jobs: jobs:
backend: backend:
name: Backend vet, build, and test name: Backend vet, build, and test
@@ -25,7 +27,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
repository: ${{ github.event.pull_request.base.repo.full_name }} repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.action == 'closed' && github.event.pull_request.base.ref || github.ref }} ref: ${{ github.sha }}
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
@@ -34,6 +36,7 @@ jobs:
cache-dependency-path: | cache-dependency-path: |
go.sum go.sum
relaykit/go.sum relaykit/go.sum
tokenkit/go.sum
- name: Create frontend embed placeholder - name: Create frontend embed placeholder
run: | run: |
@@ -47,6 +50,10 @@ jobs:
working-directory: relaykit working-directory: relaykit
run: go vet ./... run: go vet ./...
- name: Vet tokenkit independently
working-directory: tokenkit
run: go vet ./...
- name: Build root module - name: Build root module
run: go build ./... run: go build ./...
@@ -54,7 +61,11 @@ jobs:
working-directory: relaykit working-directory: relaykit
run: go build ./... run: go build ./...
- name: Test root and relaykit modules - name: Build tokenkit independently
working-directory: tokenkit
run: go build ./...
- name: Test root, relaykit, and tokenkit modules
run: make test run: make test
frontend: frontend:
@@ -71,7 +82,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
repository: ${{ github.event.pull_request.base.repo.full_name }} repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.action == 'closed' && github.event.pull_request.base.ref || github.ref }} ref: ${{ github.sha }}
- name: Set up Bun - name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+3 -3
View File
@@ -39,7 +39,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with: with:
go-version: '>=1.25.1' go-version: '>=1.26'
- name: Build frontend - name: Build frontend
env: env:
@@ -55,13 +55,13 @@ jobs:
# if: runner.os != 'Windows' # if: runner.os != 'Windows'
# run: | # run: |
# go mod download # go mod download
# go build -ldflags "-s -w -X 'new-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api # go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api
- name: Build Go binary (Windows) - name: Build Go binary (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
run: | run: |
go mod download go mod download
go build -ldflags "-s -w -X 'new-api/common.Version=$(git describe --tags)'" -o new-api.exe go build -pgo="$(go list -m -f '{{.Dir}}' github.com/Calcium-Ion/moejs)/default.pgo" -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(git describe --tags)'" -o new-api.exe
- name: Update Electron version - name: Update Electron version
run: | run: |
+7 -7
View File
@@ -44,16 +44,16 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with: with:
go-version: '>=1.25.1' go-version: '>=1.26'
- name: Build Backend (amd64) - name: Build Backend (amd64)
run: | run: |
go mod download go mod download
go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION go build -pgo="$(go list -m -f '{{.Dir}}' github.com/Calcium-Ion/moejs)/default.pgo" -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION
- name: Build Backend (arm64) - name: Build Backend (arm64)
run: | run: |
sudo apt-get update sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -pgo="$(go list -m -f '{{.Dir}}' github.com/Calcium-Ion/moejs)/default.pgo" -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION
- name: Generate checksums - name: Generate checksums
run: sha256sum new-api-* > checksums-linux.txt run: sha256sum new-api-* > checksums-linux.txt
@@ -98,11 +98,11 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with: with:
go-version: '>=1.25.1' go-version: '>=1.26'
- name: Build Backend - name: Build Backend
run: | run: |
go mod download go mod download
go build -ldflags "-X 'new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION go build -pgo="$(go list -m -f '{{.Dir}}' github.com/Calcium-Ion/moejs)/default.pgo" -ldflags "-X 'github.com/QuantumNous/new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION
- name: Generate checksums - name: Generate checksums
run: shasum -a 256 new-api-macos-* > checksums-macos.txt run: shasum -a 256 new-api-macos-* > checksums-macos.txt
@@ -149,11 +149,11 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with: with:
go-version: '>=1.25.1' go-version: '>=1.26'
- name: Build Backend - name: Build Backend
run: | run: |
go mod download go mod download
go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe go build -pgo="$(go list -m -f '{{.Dir}}' github.com/Calcium-Ion/moejs)/default.pgo" -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe
- name: Generate checksums - name: Generate checksums
run: sha256sum new-api-*.exe > checksums-windows.txt run: sha256sum new-api-*.exe > checksums-windows.txt
+11 -6
View File
@@ -8,18 +8,19 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
## Tech Stack ## Tech Stack
- **Backend**: Go 1.25.1 (see each module’s `go.mod`), Gin web framework, GORM v2 ORM - **Backend**: Go 1.26 for the root module and `tokenkit/`, Go 1.25.1 for `relaykit/` (see each module’s `go.mod`), Gin web framework, GORM v2 ORM
- **Frontend**: React 19, TypeScript, Rsbuild 2, TanStack Router/Query/Table, Zustand, Base UI, Tailwind CSS 4 - **Frontend**: React 19, TypeScript, Rsbuild 2, TanStack Router/Query/Table, Zustand, Base UI, Tailwind CSS 4
- **Databases**: SQLite, MySQL, PostgreSQL for the primary database (all three must be supported); a separately configured log database also supports ClickHouse - **Databases**: SQLite, MySQL, PostgreSQL for the primary database (all three must be supported); a separately configured log database also supports ClickHouse
- **Cache**: Redis (go-redis) + in-memory cache - **Cache**: Redis (go-redis) + in-memory cache
- **Auth**: Browser sessions, API tokens and personal access tokens, JWT, WebAuthn/Passkeys, TOTP, OAuth/OIDC; Casbin authorization in `service/authz/` - **Auth**: Browser sessions, API tokens and personal access tokens, JWT, WebAuthn/Passkeys, TOTP, OAuth/OIDC; Casbin authorization in `service/authz/`
- **Extensions**: JavaScript task plugins executed by Sobek; Electron desktop wrapper - **Extensions**: JavaScript task plugins executed by moejs; Electron desktop wrapper
- **Frontend package manager**: Bun (preferred over npm/yarn/pnpm) - **Frontend package manager**: Bun (preferred over npm/yarn/pnpm)
## Architecture ## Architecture
- The Go gateway handles management APIs, upstream relay, billing, and background tasks across `router/`, `middleware/`, `controller/`, `service/`, `model/`, and `relay/`. - The Go gateway handles management APIs, upstream relay, billing, and background tasks across `router/`, `middleware/`, `controller/`, `service/`, `model/`, and `relay/`.
- `relaykit/` is an independent Go module for protocol DTOs and conversions; transport, authentication, database access, and billing stay in the host. - `relaykit/` is an independent Go module for protocol DTOs and conversions; transport, authentication, database access, and billing stay in the host.
- `tokenkit/` is an independent Go module for text and image token counting and estimation; request parsing, file loading, and billing stay in the host.
- JavaScript task plugins live in `plugins/tasks/`, run through `pkg/jsplugin/`, and integrate with host task polling and settlement. - JavaScript task plugins live in `plugins/tasks/`, run through `pkg/jsplugin/`, and integrate with host task polling and settlement.
- `web/` is the React frontend (see `web/AGENTS.md`); `electron/` is the desktop wrapper. - `web/` is the React frontend (see `web/AGENTS.md`); `electron/` is the desktop wrapper.
@@ -27,7 +28,11 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
### Backend (`i18n/`) ### Backend (`i18n/`)
- Library: `nicksnyder/go-i18n/v2` - Library: `nicksnyder/go-i18n/v2`
- Languages: en, zh - Languages: en, zh-CN, zh-TW
- Scope: text with no web console in between (errors returned to AI clients, emails, notifications), translated with `i18n.T` / `i18n.Translate`
- Web console API messages are English source text built with `common.NewMessage` / `common.ApiErrorT` / `common.ApiSuccessT`; the frontend translates them, so add each key to all seven frontend locale files
- Stored log content is `[]*common.Message`; a fail reason written by the gateway is a fixed English sentence that is also a key in the frontend locale files
- Server log lines are English text passed through `common.LogText`; `DEFAULT_LANGUAGE=zh-CN` prints the Chinese text of the lines that have one in `common/log_text.zh-CN.json`, and is the language of backend messages for a reader who states none
### Frontend (`web/src/i18n/`) ### Frontend (`web/src/i18n/`)
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector` - Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
@@ -72,10 +77,10 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
- Remove ineffective `omitempty` tags on non-pointer struct fields only after confirming the active JSON encoder preserves the same output. Do not change field types or omission behavior as part of a style cleanup; optional relay scalar fields must still follow the pointer rules below. - Remove ineffective `omitempty` tags on non-pointer struct fields only after confirming the active JSON encoder preserves the same output. Do not change field types or omission behavior as part of a style cleanup; optional relay scalar fields must still follow the pointer rules below.
- Format modified Go files with `gofmt` and remove unused imports after these changes. - Format modified Go files with `gofmt` and remove unused imports after these changes.
**relaykit module independence:** The `relaykit/` Go module MUST remain independently buildable. **relaykit and tokenkit module independence:** The `relaykit/` and `tokenkit/` Go modules MUST remain independently buildable.
- Code under `relaykit/` MUST NOT import or depend on packages from the root `new-api` module, or rely on root-only configuration, generated files, or workspace wiring. - Code under `relaykit/` or `tokenkit/` MUST NOT import or depend on packages from the root `new-api` module, or rely on root-only configuration, generated files, or workspace wiring. `tokenkit/` also MUST NOT depend on `relaykit/`.
- Any change affecting `relaykit/` or its public APIs MUST be verified with `cd relaykit && GOWORK=off go build ./...`; a successful root-module build is not sufficient. - Any change affecting `relaykit/`, `tokenkit/`, or their public APIs MUST be verified with `GOWORK=off go build ./...` inside that module; a successful root-module build is not sufficient.
**JSON package:** In the root Go module, all JSON marshal/unmarshal operations MUST use the wrapper functions in `common/json.go`: **JSON package:** In the root Go module, all JSON marshal/unmarshal operations MUST use the wrapper functions in `common/json.go`:
+10 -5
View File
@@ -1,6 +1,6 @@
# 自定义版本说明 # 自定义版本说明
当前版本为 v1.0.0-rc.38-custom.67-20261004,基于官方 v1.0.0-rc.38,对应 2026-10-04 已部署的自定义源码。 当前版本为 v1.0.0-rc.45-custom.97,基于官方 v1.0.0-rc.45。
## 主要定制 ## 主要定制
@@ -12,16 +12,22 @@
- 主站 /group-status 使用真实分组名并展示全部分组,检测请求使用真实分组标识;独立 uptime 页应用公开展示配置。 - 主站 /group-status 使用真实分组名并展示全部分组,检测请求使用真实分组标识;独立 uptime 页应用公开展示配置。
- 渠道每 15 分钟探活,展示最近 18 次记录;正常用户请求可恢复分组正常状态。 - 渠道每 15 分钟探活,展示最近 18 次记录;正常用户请求可恢复分组正常状态。
- 两页共用分组行布局,正常与性能下降分组共同优先,同类状态中支持 Astra 的分组排在前面,其他分组使用紧凑布局;鹈鹕样例按时间倒序横向滚动,最新在左,点击缩略图预览。 - 两页共用分组行布局,正常与性能下降分组共同优先,同类状态中支持 Astra 的分组排在前面,其他分组使用紧凑布局;鹈鹕样例按时间倒序横向滚动,最新在左,点击缩略图预览。
- 鹈鹕检测通过 manxue.ai 访客接口请求公开作品,保存可跳转的作品链接;连接失败时本地生成并标记为不确定。
- 作品脚本在隔离 iframe 中运行,以保留腿部、踏板等动画绘制;缩略图按容器宽度等比缩放。 - 作品脚本在隔离 iframe 中运行,以保留腿部、踏板等动画绘制;缩略图按容器宽度等比缩放。
## custom.97
- 视觉检测区分能力残缺、疑似降智和运行失败,保留各项检测的独立有效结论。
- 按分组与模型展示吞吐量、首字时间:各自优先使用最近 30 次有效调用,缺少调用时使用测试数据。
- 自动分组新增高吞吐优先策略;网站图标可通过构建变量配置。
- Astra 优先分组预设从当前分组池选择,不绑定特定供应商。
## 从源码构建 ## 从源码构建
docker build -f Dockerfile.local-release -t local/new-api:v1.0.0-rc.38-custom.67-20261004 . docker build -f Dockerfile.local-release -t local/new-api:v1.0.0-rc.45-custom.97 .
此入口从 Bun、Go 和 Debian 基础镜像构建前端与服务,不要求预先存在旧版 local/new-api 镜像。VERSION 用于编译版本号;Compose 如果设置了 VERSION 环境变量,也应同步更新。 此入口从 Bun、Go 和 Debian 基础镜像构建前端与服务,不要求预先存在旧版 local/new-api 镜像。VERSION 用于编译版本号;Compose 如果设置了 VERSION 环境变量,也应同步更新。
其他 Dockerfile.* 和 build-fingerprint-backend.sh 保留为历史构建参考。部分增量构建文件依赖服务器上的旧版镜像或已编译二进制,新环境请使用 Dockerfile.local-release。 构建需要 Bun 和 Go 1.26;Dockerfile.local-release 包含完整的前端与后端构建过程。
## 运行与升级 ## 运行与升级
@@ -29,7 +35,6 @@
升级前备份数据库及 Compose 配置,部署后检查容器健康、版本号、主站分组状态和公开 uptime 页面。Uptime Kuma 的停用与域名反向代理属于部署配置,不会仅因替换应用镜像自动完成。 升级前备份数据库及 Compose 配置,部署后检查容器健康、版本号、主站分组状态和公开 uptime 页面。Uptime Kuma 的停用与域名反向代理属于部署配置,不会仅因替换应用镜像自动完成。
公开作品链接受 manxue.ai 的保留期限约束;没有公开链接的历史作品继续使用本站预览。
## custom.44 current availability ## custom.44 current availability
+5 -4
View File
@@ -7,7 +7,7 @@ COPY ./web ./
COPY ./VERSION /build/VERSION COPY ./VERSION /build/VERSION
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build 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 ENV GO111MODULE=on CGO_ENABLED=0 GOWORK=off
ARG TARGETOS ARG TARGETOS
@@ -18,9 +18,10 @@ ENV GOEXPERIMENT=greenteagc
WORKDIR /build WORKDIR /build
ADD go.mod go.sum ./ ADD go.mod go.sum ./
# relaykit is a local submodule referenced via replace; its go.mod must be # relaykit and tokenkit are local submodules referenced via replace; their
# present for go mod download to resolve the main module graph. # go.mod files must be present for go mod download to resolve the main module graph.
ADD relaykit/go.mod ./relaykit/go.mod ADD relaykit/go.mod ./relaykit/go.mod
ADD tokenkit/go.mod ./tokenkit/go.mod
RUN go mod download RUN go mod download
COPY . . COPY . .
@@ -28,7 +29,7 @@ COPY --from=builder /build/web/dist ./web/dist
RUN go test ./service -run TestAppendStreamStatusTreatsClientGoneAsDisplayNormal RUN go test ./service -run TestAppendStreamStatusTreatsClientGoneAsDisplayNormal
RUN go test ./controller -run 'TestClientFacing(OpenAIErrorHidesUpstreamDetails|ClaudeErrorHidesUpstreamDetails|ErrorKeepsLocalValidationMessage)' RUN go test ./controller -run 'TestClientFacing(OpenAIErrorHidesUpstreamDetails|ClaudeErrorHidesUpstreamDetails|ErrorKeepsLocalValidationMessage)'
RUN go test ./controller -run TestPublicUpstreamErrorContent RUN go test ./controller -run TestPublicUpstreamErrorContent
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api RUN go build -pgo="$(go list -m -f '{{.Dir}}' github.com/Calcium-Ion/moejs)/default.pgo" -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
-3
View File
@@ -1,3 +0,0 @@
FROM local/new-api:v1.0.0-rc.38-custom.45-20260926
COPY new-api /new-api
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.46-20260927"
-4
View File
@@ -1,4 +0,0 @@
FROM local/new-api:v1.0.0-rc.38-custom.59-20260929
COPY new-api /new-api
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.60-20260929"
LABEL custom.patch="browser-performance"
-3
View File
@@ -1,3 +0,0 @@
FROM local/new-api:v1.0.0-rc.38-custom.66-20261004
COPY new-api /new-api
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.67"
-3
View File
@@ -1,3 +0,0 @@
FROM local/new-api:v1.0.0-rc.38-custom.44-20260926
COPY new-api /new-api
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.45-20260926"
-3
View File
@@ -1,3 +0,0 @@
FROM local/new-api:v1.0.0-rc.38-custom.51-20260928
COPY new-api /new-api
LABEL custom.patch="automatic-routing-pool-failover-model-costs"
-3
View File
@@ -1,3 +0,0 @@
FROM local/new-api:v1.0.0-rc.38-custom.43-20260926
COPY new-api /new-api
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.44-20260926"
-3
View File
@@ -1,3 +0,0 @@
FROM local/new-api:v1.0.0-rc.38-custom.24-20260923
COPY new-api /new-api
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.25-20260923"
+4 -3
View File
@@ -1,7 +1,7 @@
# Backend-only build for frontend development # Backend-only build for frontend development
# Skips frontend build and uses a placeholder for //go:embed web/dist # 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@sha256:9ce231b28998b32e116927c61aa5c406183061d4f3f2d61b292c88e7f6bdf7ca AS builder
ENV GO111MODULE=on CGO_ENABLED=0 ENV GO111MODULE=on CGO_ENABLED=0
ARG TARGETOS ARG TARGETOS
@@ -12,9 +12,10 @@ ENV GOEXPERIMENT=greenteagc
WORKDIR /build WORKDIR /build
ADD go.mod go.sum ./ ADD go.mod go.sum ./
# relaykit is a local submodule referenced via replace; its go.mod must be # relaykit and tokenkit are local submodules referenced via replace; their
# present for go mod download to resolve the main module graph. # go.mod files must be present for go mod download to resolve the main module graph.
ADD relaykit/go.mod ./relaykit/go.mod ADD relaykit/go.mod ./relaykit/go.mod
ADD tokenkit/go.mod ./tokenkit/go.mod
RUN go mod download RUN go mod download
COPY . . COPY . .
-3
View File
@@ -1,3 +0,0 @@
FROM local/new-api:v1.0.0-rc.38-custom.50-20260927
COPY new-api /new-api
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.51-20260928"
-3
View File
@@ -1,3 +0,0 @@
FROM local/new-api:v1.0.0-rc.38-custom.65-20261004
COPY new-api /new-api
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.66"
-4
View File
@@ -1,4 +0,0 @@
FROM local/new-api:v1.0.0-rc.38-custom.26-20260923
COPY new-api /new-api
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/
-3
View File
@@ -1,3 +0,0 @@
FROM local/new-api:v1.0.0-rc.38-custom.47-20260927
COPY new-api /new-api
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.48-20260927"
-24
View File
@@ -1,24 +0,0 @@
FROM oven/bun:1.4.0 AS webbuild
WORKDIR /build/web
COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile
COPY web ./
COPY VERSION /build/VERSION
RUN bun run typecheck
RUN bun run test src/features/pricing/__tests__/group-pricing.test.ts src/features/pricing/__tests__/dynamic-price.test.ts
RUN bunx oxlint -c .oxlintrc.json src/features/pricing/types.ts src/features/pricing/lib/dynamic-price.ts src/features/pricing/components/model-card.tsx src/features/pricing/components/model-details.tsx src/features/pricing/__tests__/group-pricing.test.ts
RUN VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
FROM local/new-api:group-cache-status-tests-20260905 AS build
ENV CGO_ENABLED=0 GOWORK=off GOEXPERIMENT=greenteagc
WORKDIR /build
COPY go.mod go.sum ./
COPY relaykit/go.mod ./relaykit/go.mod
RUN go mod download
COPY . .
COPY --from=webbuild /build/web/dist ./web/dist
RUN go test ./relay/helper ./setting/billing_setting ./controller ./service -run 'TestGroupModelPricing|TestModelPriceHelperTiered|TestAppendStreamStatusTreatsClientGoneAsDisplayNormal|TestClientFacing|TestPublicUpstreamErrorContent'
RUN go build -ldflags "-s -w -X github.com/QuantumNous/new-api/common.Version=$(cat VERSION)" -o /new-api
FROM local/new-api:v1.0.0-rc.30-group-cache-status-20260906-r4
COPY --from=build /new-api /new-api
+3 -3
View File
@@ -3,9 +3,9 @@ WORKDIR /build/web
COPY web/package.json web/bun.lock ./ COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
COPY web/ ./ COPY web/ ./
RUN bun run build RUN VITE_REACT_APP_FAVICON=/custom-site-icon.png bun run build
FROM golang:1.25.1 AS build FROM golang:1.26.10@sha256:672c944cfd07599b5f1bb179869270cdc94c6254e41db4d431259693cb286b82 AS build
ENV GOMAXPROCS=2 GOFLAGS=-p=2 GO111MODULE=on CGO_ENABLED=0 GOWORK=off ENV GOMAXPROCS=2 GOFLAGS=-p=2 GO111MODULE=on CGO_ENABLED=0 GOWORK=off
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
@@ -25,7 +25,7 @@ RUN apt-get update \
COPY --from=build /build/new-api / COPY --from=build /build/new-api /
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/ COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/ COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.67-20261004" LABEL org.opencontainers.image.version="v1.0.0-rc.45-custom.97"
EXPOSE 3000 EXPOSE 3000
WORKDIR /data WORKDIR /data
ENTRYPOINT ["/new-api"] ENTRYPOINT ["/new-api"]
-9
View File
@@ -1,9 +0,0 @@
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget && rm -rf /var/lib/apt/lists/* && update-ca-certificates
COPY new-api /new-api
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.64"
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/new-api"]
-10
View File
@@ -1,10 +0,0 @@
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget && rm -rf /var/lib/apt/lists/* && update-ca-certificates
COPY new-api /new-api
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/
COPY pkg/model_benchmark/LICENSE-*.txt /licenses/model-benchmark/
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.65"
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/new-api"]
-3
View File
@@ -1,3 +0,0 @@
FROM local/new-api:v1.0.0-rc.38-custom.38-20260926
COPY new-api /new-api
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.39-20260926"
-47
View File
@@ -1,47 +0,0 @@
FROM oven/bun:1.4.0@sha256:5ff609364c049b54eb0ff560ec96319729a972078ef2c755d758f0c6ef89c2d6 AS webdeps
WORKDIR /build/web
COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile
FROM node:24-alpine@sha256:50c8e8ca1d27439048670df5883f32d57cf81cff6233222c893fd0d9884cbd81 AS webbuild
WORKDIR /build/web
COPY --from=webdeps /build/web/node_modules ./node_modules
COPY web ./
COPY VERSION /build/VERSION
COPY pkg/billingexpr/testdata /build/pkg/billingexpr/testdata
RUN npm run typecheck
RUN node node_modules/vitest/vitest.mjs run src/features/usage-logs/components/__tests__ src/features/system-settings/request-policies src/features/group-status/__tests__ src/features/pricing/__tests__/group-pricing.test.ts src/features/keys/lib/__tests__/auto-group-form.test.ts src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx src/features/keys/components/__tests__/astra-cost-notice.test.tsx src/features/channels/components/__tests__/responses-websocket-setting.test.tsx src/features/profile/__tests__/settings.test.tsx
RUN npx --no-install oxlint -c .oxlintrc.json src/features/group-status src/features/dashboard/types.ts src/features/keys/components/astra-cost-notice.tsx src/features/keys/components/__tests__/astra-cost-notice.test.tsx src/features/keys/components/api-keys-mutate-drawer.tsx src/features/keys/lib/api-key-form.ts src/features/keys/types.ts src/features/channels/components/astra-model-guard-setting.tsx src/features/channels/components/drawers/channel-mutate-drawer.tsx src/features/channels/components/__tests__/responses-websocket-setting.test.tsx src/features/channels/lib/channel-form.ts src/features/channels/lib/channel-configuration.ts src/features/profile/lib/user-settings.ts src/features/profile/types.ts src/features/usage-logs/components/routing-attempts.tsx src/features/usage-logs/components/model-badge.tsx src/features/usage-logs/components/common-logs-filter-bar.tsx
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) npm run build
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS build
ENV GOMAXPROCS=2 GOFLAGS=-p=2 GO111MODULE=on CGO_ENABLED=0 GOWORK=off GOEXPERIMENT=greenteagc
ARG TARGETOS
ARG TARGETARCH
ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64}
WORKDIR /build
ADD go.mod go.sum ./
ADD relaykit/go.mod ./relaykit/go.mod
RUN go mod download
COPY . .
COPY --from=webbuild /build/web/dist ./web/dist
RUN go test ./pkg/perf_metrics ./model ./controller ./service ./relay/helper -run 'TestGroupBuckets|TestRequestStatus|TestGroupModelPricing|TestGetEnabledGroupSetRequiresEnabledAbilityAndChannel|TestSelectableGroupsRequireEnabledChannel|TestAddTokenPersistsFixedBackupGroup|TestUpdateTokenAutoGroupsTriStateAndFixedBackup|TestAddTokenRejectsInvalidFixedBackupGroup|TestCacheGetRandomSatisfiedChannelUsesTokenAutoGroupsWhenGlobalAutoIsEmpty|TestCacheGetRandomSatisfiedChannelUsesFixedTokenBackupGroup|TestCacheGetRandomSatisfiedChannelSkipsUnavailableFixedPrimary|TestAppendStreamStatusTreatsClientGoneAsDisplayNormal|TestClientFacingOpenAIErrorHidesUpstreamDetails|TestClientFacingClaudeErrorHidesUpstreamDetails|TestClientFacingErrorKeepsLocalValidationMessage|TestPublicUpstreamErrorContent' -count=1
RUN go test ./model ./controller ./service -run 'TestChannelGetSettingDefaultsAstraGuardOn|TestFixedBackup|TestShouldSkipRetryAfterChannelAffinityFailure|TestShouldRetryHonorsPinRetryMode|TestGroupPool|TestBuildGroupPoolTiers|TestNextTierIndex|TestGroupAvailability|TestGetGroupPool|TestSetGroupPool' -count=1
RUN go test ./model ./service ./controller ./relay/helper -run 'TestRequestDetails|TestSelectionTrace|TestResponsesInterruptedStreamHealth|TestResponsesWebSocketReusesConnectionAndSettlesEachRequest|TestStreamScanner' -count=1
RUN cd relaykit && GOWORK=off go build ./...
RUN go test ./relay -run 'TestResponseGuard|TestResponseModel' -count=1
RUN go test ./controller ./middleware ./service -run 'TestTokenAstraMigration|TestRelayAttemptGuardResult|TestRelayAttemptSwitchSafety|TestTokenAuth|TestAstra' -count=1
RUN go test ./pkg/model_fingerprint ./controller ./service ./router -run 'TestFingerprint|TestAstraSampling|TestSystemTask|TestChannelPermission' -count=1
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \
&& rm -rf /var/lib/apt/lists/* \
&& update-ca-certificates
COPY --from=build /build/new-api /
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/new-api"]
-3
View File
@@ -1,3 +0,0 @@
FROM local/new-api:v1.0.0-rc.38-custom.49-20260927
COPY new-api /new-api
LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.50-20260927"
+187 -313
View File
@@ -4,12 +4,13 @@
# New API # New API
🍥 **Next-Generation Large Model Gateway and AI Asset Management System** **An AI gateway for models, applications, and agents**
<p align="center"> <p align="center">
<a href="./README.md">中文</a> | <a href="./README.zh_CN.md">简体中文</a> |
<strong>English</strong> | <a href="./README.zh_TW.md">繁體中文</a> |
<a href="./README.fr.md">Français</a> | <strong>English</strong> |
<a href="./README.fr.md">Français</a> |
<a href="./README.ja.md">日本語</a> <a href="./README.ja.md">日本語</a>
</p> </p>
@@ -45,26 +46,54 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#-quick-start">Quick Start</a> • <a href="#screenshots">Screenshots</a> •
<a href="#-key-features">Key Features</a> • <a href="#capabilities">Capabilities</a> •
<a href="#-deployment">Deployment</a> • <a href="#quick-start">Quick start</a> •
<a href="#-documentation">Documentation</a> • <a href="#deployment">Deployment</a> •
<a href="#-help-support">Help</a> <a href="#development">Development</a> •
<a href="#documentation">Documentation</a>
</p> </p>
</div> </div>
---
## 📝 Project Description ## 📝 Project Description
> [!NOTE] New API is a self-hosted AI gateway for applications, agents, and teams. Connect upstream model services, expose a consistent API to your clients, and manage routing, access, usage, and costs in one place.
Use it to share authorized model access across a team, switch providers without configuring every client again, or operate a private multi-model service with a web console. Upstreams include OpenAI, Anthropic, Google Gemini, Azure OpenAI, AWS Bedrock, Vertex AI, DeepSeek, Qwen, and other compatible services.
> [!NOTE]
> This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api) > This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api)
> [!IMPORTANT] <!-- -->
> [!IMPORTANT]
> - This project is intended solely for lawful and authorized AI API gateway, organization-level authentication, multi-model management, usage analytics, cost accounting, and private deployment scenarios. > - This project is intended solely for lawful and authorized AI API gateway, organization-level authentication, multi-model management, usage analytics, cost accounting, and private deployment scenarios.
> - Users must lawfully obtain upstream API keys, accounts, model services, and interface permissions, and must comply with upstream terms of service and applicable laws and regulations. > - Users must lawfully obtain upstream API keys, accounts, model services, and interface permissions, and must comply with upstream terms of service and applicable laws and regulations.
> - Users should ensure their use complies with upstream terms of service and applicable laws and regulations. > - Users should ensure their use complies with upstream terms of service and applicable laws and regulations.
> - When providing generative AI services to the public, users should comply with applicable regulatory requirements and fulfill all filing, licensing, content safety, real-name verification, log retention, tax, and upstream authorization obligations required by their jurisdiction. > - When providing generative AI services to the public, users should comply with applicable regulatory requirements and fulfill all filing, licensing, content safety, real-name verification, log retention, tax, and upstream authorization obligations required by their jurisdiction.
<!-- -->
> [!WARNING]
> When operating this project as a public generative AI service or API resale service, users should first complete all required filing, licensing, content safety, real-name verification, log retention, tax, payment, and upstream authorization obligations.
---
<a id="screenshots"></a>
## Screenshots
Accounts, channels, plugin installation states, usage, prices, and costs shown below are simulated data. The marketplace displays the official plugin catalog. Click an image to view it at full size.
| Usage dashboard | Plugin marketplace |
| --- | --- |
| [![Usage dashboard with simulated requests, costs, and performance metrics](assets/screenshots/dashboard.en.jpg)](assets/screenshots/dashboard.en.jpg) | [![Official plugin marketplace with simulated installation status](assets/screenshots/plugin-marketplace.en.jpg)](assets/screenshots/plugin-marketplace.en.jpg) |
| **Model square** | **Usage logs** |
| [![Model square with simulated model pricing and availability](assets/screenshots/models.en.jpg)](assets/screenshots/models.en.jpg) | [![Usage logs with simulated tokens, costs, and response times](assets/screenshots/usage-logs.en.jpg)](assets/screenshots/usage-logs.en.jpg) |
--- ---
## 🤝 Trusted Partners ## 🤝 Trusted Partners
@@ -80,9 +109,6 @@
<a href="https://bda.pku.edu.cn/" target="_blank"> <a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="Peking University" height="80" /> <img src="./docs/images/pku.png" alt="Peking University" height="80" />
</a> </a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
</a>
<a href="https://www.aliyun.com/" target="_blank"> <a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" /> <img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
</a> </a>
@@ -107,324 +133,174 @@
--- ---
## 🚀 Quick Start <a id="capabilities"></a>
### Using Docker Compose (Recommended) ## Capabilities
| Area | What you can do |
| --- | --- |
| Model access | Use OpenAI Chat Completions, Responses, Anthropic Messages, and Gemini APIs; stream responses and use tools, reasoning, and multimodal inputs where supported |
| Routing | Configure model mappings, channel priorities and weights, retries, channel affinity, and multiple upstream keys |
| Usage and costs | Manage quotas, subscriptions, usage logs, cache accounting, and expression-based pricing for different usage tiers |
| Access control | Manage users, groups, fine-grained permissions, and API key restrictions; use OAuth/OIDC, passkeys, two-factor authentication, and login session management |
| Asynchronous tasks | Extend image, video, and other task APIs with JavaScript plugins, including task status and output retrieval |
| Web console | Configure channels and models, inspect usage and audit logs, and try models in the playground; available in English, Simplified Chinese, Traditional Chinese, French, Japanese, Russian, and Vietnamese |
### Protocols and endpoints
| Interface | Common endpoints |
| --- | --- |
| OpenAI Chat / Responses | `POST /v1/chat/completions`, `POST /v1/responses` |
| Anthropic Messages | `POST /v1/messages` |
| Gemini | `POST /v1beta/models/{model}:generateContent`, `POST /v1beta/models/{model}:streamGenerateContent` |
| Realtime / Responses WebSocket | `GET /v1/realtime`, `GET /v1/responses` (WebSocket upgrade) |
| Images / audio | `/v1/images/generations`, `/v1/images/edits`, `/v1/audio/speech`, `/v1/audio/transcriptions`, `/v1/audio/translations` |
| Embeddings / rerank | `POST /v1/embeddings`, `POST /v1/rerank` |
| Task plugins | `POST /v1/tasks/{pluginKey}`, `GET /v1/tasks/{taskId}`, plus routes declared by each plugin |
[RelayKit](./relaykit/README.md) provides request, response, and streaming conversion between the four text protocols. Available features depend on the channel, upstream model, and conversion path; protocol-specific tools and fields may not map exactly. WebSocket support also requires a compatible upstream and channel configuration.
This README describes the current source tree. Check the release notes for the version you deploy.
<a id="quick-start"></a>
## Quick start
### Try locally with Docker
This starts a single instance with SQLite and binds it to localhost:
```bash
mkdir -p data
docker run --name new-api -d --restart unless-stopped \
-p 127.0.0.1:3000:3000 \
-e TZ=Asia/Shanghai \
-v "$(pwd)/data:/data" \
calciumion/new-api:latest
```
Open [http://localhost:3000](http://localhost:3000) and complete the setup wizard to create the administrator account. The `data` directory persists the SQLite database across container replacements.
### Make your first request
1. Add a channel with your upstream API key, available models, and group assignment; run a channel test.
2. Configure model pricing and ensure the user has quota or a valid subscription.
3. Create an API key in the console with access to the same group and models.
4. Set your client's base URL to `http://localhost:3000/v1` for OpenAI-compatible clients and use the **New API-issued key**.
Set `NEW_API_KEY` in your shell to that key. List the models accessible to it:
```bash
curl --fail-with-body http://localhost:3000/v1/models \
-H "Authorization: Bearer ${NEW_API_KEY}"
```
Then call Responses, replacing `your-enabled-model` with an enabled model that supports this interface:
```bash
curl --fail-with-body http://localhost:3000/v1/responses \
-H "Authorization: Bearer ${NEW_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"model":"your-enabled-model","input":"Hello!"}'
```
<a id="deployment"></a>
## Deployment
### Docker Compose
The repository's [Compose configuration](./docker-compose.yml) starts **New API + PostgreSQL + Redis** by default. It also contains examples for MySQL and a separate ClickHouse log database.
```bash ```bash
# Clone the project
git clone https://github.com/QuantumNous/new-api.git git clone https://github.com/QuantumNous/new-api.git
cd new-api cd new-api
# Edit docker-compose.yml configuration
nano docker-compose.yml
# Start the service
docker-compose up -d
``` ```
<details> Before starting, edit `docker-compose.yml`: replace the database and Redis example passwords in both the services and connection strings, and set a persistent random `SESSION_SECRET` (generate one with `openssl rand -hex 32`). For an HTTPS console, configure `SESSION_COOKIE_SECURE=true` and `SESSION_COOKIE_TRUSTED_URL` with its exact public HTTPS origin.
<summary><strong>Using Docker Commands</strong></summary>
```bash ```bash
# Pull the latest image docker compose up -d
docker pull calciumion/new-api:latest docker compose logs -f new-api
# Using SQLite (default)
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# Using MySQL
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
``` ```
> **💡 Tip:** `-v ./data:/data` will save data in the `data` folder of the current directory, you can also change it to an absolute path like `-v /your/custom/path:/data` ### Storage and configuration
</details> | Component | Options |
| --- | --- |
| Main database | SQLite, MySQL ≥ 5.7.8, or PostgreSQL ≥ 9.6 |
| Separate log database | Configure with `LOG_SQL_DSN`; also supports ClickHouse |
| Cache | Optional Redis plus in-memory caching; use shared Redis when application nodes need shared rate limits |
| Container platforms | Linux amd64 / arm64 |
--- | Variable | Purpose |
| --- | --- |
| `SQL_DSN` | Main database connection; unset uses SQLite |
| `LOG_SQL_DSN` | Optional separate log database connection |
| `REDIS_CONN_STRING` | Redis connection string |
| `SESSION_SECRET` | Persistent authentication secret; all nodes must use the same value |
| `CRYPTO_SECRET` | Defaults to `SESSION_SECRET`; nodes sharing Redis must use the same effective value |
| `SESSION_COOKIE_SECURE` | Set to `true` for an HTTPS console; enables Secure refresh cookies and strict refresh/logout origin checks |
| `SESSION_COOKIE_TRUSTED_URL` | Required in Secure mode: comma-separated exact HTTPS origins, without paths or wildcards; leave unset for local HTTP |
| `TRUSTED_PROXIES` | Trusted reverse-proxy IPs/CIDRs, or `none`; explicitly configure for your network |
🎉 After deployment is complete, visit `http://localhost:3000` to start using! See the [environment example](./.env.example), [environment reference](https://docs.newapi.ai/en/docs/installation/config-maintenance/environment-variables), and [authentication and session guide](./docs/authentication.md) for full configuration. Configure container variables in Compose's `environment` or `env_file`; copying `.env.example` alone does not inject variables into the container.
> [!WARNING] For production, put the console behind HTTPS and configure your reverse proxy for streaming and WebSocket upgrades. Persist and back up the database and mounted data. Multi-node deployments must share the main database and authentication secrets; separate Redis instances or in-memory rate limiters count limits independently per node. The session guide describes propagation behavior for each topology.
> When operating this project as a public generative AI service or API resale service, users should first complete all required filing, licensing, content safety, real-name verification, log retention, tax, payment, and upstream authorization obligations.
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation) Pin an image version from [Releases](https://github.com/QuantumNous/new-api/releases), review its upgrade notes, and back up before upgrading. The `latest` tag follows published builds and can change; migrations and compatibility must be assessed for your existing installation.
--- <a id="development"></a>
## 📚 Documentation ## Development and extensions
<div align="center"> The backend uses Go and Gin. The web console uses React 19, TypeScript, Rsbuild, TanStack, and Tailwind CSS 4. Use Bun for frontend dependencies and scripts; see [go.mod](./go.mod) for the Go language baseline and [Dockerfile](./Dockerfile) for the container build toolchain.
### 📖 [Official Documentation](https://docs.newapi.pro/en/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) Build the frontend before starting the backend, which embeds `web/dist`:
</div>
**Quick Navigation:**
| Category | Link |
|------|------|
| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/en/docs/installation) |
| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) |
| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/en/docs/api) |
| ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
---
## ✨ Key Features
> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction)
### 🎨 Core Functions
| Feature | Description |
|------|------|
| 🎨 New UI | Modern user interface design |
| 🌍 Multi-language | Supports Chinese, English, French, Japanese |
| 🔄 Data Compatibility | Fully compatible with the original One API database |
| 📈 Data Dashboard | Visual console and statistical analysis |
| 🔒 Permission Management | Token grouping, model restrictions, user management |
### 💰 Authorized Usage Accounting and Billing
- ✅ Internal top-up and quota allocation for lawful authorized scenarios (EPay, Stripe)
- ✅ Organization-level per-request, usage-based, and cache-hit cost accounting
- ✅ Cache billing statistics for OpenAI, Azure, DeepSeek, Claude, Qwen, and supported models
- ✅ Flexible billing policies for internal management or authorized enterprise customers
### 🔐 Authorization and Security
- 😈 Discord authorization login
- 🤖 LinuxDO authorization login
- 📱 Telegram authorization login
- 🔑 OIDC unified authentication
### 🚀 Advanced Features
**API Format Support:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (including Azure)
- ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
- ⚡ [Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
- 🔄 [Rerank Models](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina)
**Intelligent Routing:**
- ⚖️ Channel weighted random
- 🔄 Automatic retry on failure
- 🚦 User-level model rate limiting
**Format Conversion:**
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
- 🔄 **OpenAI Compatible → Google Gemini**
- 🔄 **Google Gemini → OpenAI Compatible** - Text only, function calling not supported yet
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - In development
- 🔄 **Thinking-to-content functionality**
**Reasoning Effort Support:**
<details>
<summary>View detailed configuration</summary>
**OpenAI series models:**
- `o3-mini-high` - High reasoning effort
- `o3-mini-medium` - Medium reasoning effort
- `o3-mini-low` - Low reasoning effort
- `gpt-5-high` - High reasoning effort
- `gpt-5-medium` - Medium reasoning effort
- `gpt-5-low` - Low reasoning effort
**Claude thinking models:**
- `claude-3-7-sonnet-20250219-thinking` - Enable thinking mode
**Google Gemini series models:**
- `gemini-2.5-flash-thinking` - Enable thinking mode
- `gemini-2.5-flash-nothinking` - Disable thinking mode
- `gemini-2.5-pro-thinking` - Enable thinking mode
- `gemini-2.5-pro-thinking-128` - Enable thinking mode with thinking budget of 128 tokens
- You can also append `-low`, `-medium`, or `-high` to any Gemini model name to request the corresponding reasoning effort (no extra thinking-budget suffix needed).
</details>
---
## 🤖 Model Support
> For details, please refer to [API Documentation - Gateway Interface](https://docs.newapi.pro/en/docs/api)
| Model Type | Description | Documentation |
|---------|------|------|
| 🤖 OpenAI GPTs | gpt-4-gizmo-* series | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/en/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/en/api/suno-music) |
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) |
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) |
| 🌐 Gemini | Google Gemini format | [Documentation](https://doc.newapi.pro/en/api/google-gemini-chat) |
| 🔧 Dify | ChatFlow mode | - |
| 🎯 Custom upstream | Supports configuring legally authorized upstream endpoints | - |
### 📡 Supported Interfaces
<details>
<summary>View complete interface list</summary>
- [Chat Interface (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion)
- [Response Interface (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
- [Image Interface (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/v1-images-generations--post)
- [Audio Interface (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
- [Video Interface (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/sora/createvideo)
- [Embedding Interface (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/create-embedding)
- [Rerank Interface (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank)
- [Realtime Conversation (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session)
- [Claude Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
- [Google Gemini Chat](https://doc.newapi.pro/en/api/google-gemini-chat)
</details>
---
## 🚢 Deployment
> [!TIP]
> **Latest Docker image:** `calciumion/new-api:latest`
### 📋 Deployment Requirements
| Component | Requirement |
|------|------|
| **Local database** | SQLite (Docker must mount `/data` directory)|
| **Remote database** | MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 |
| **Container engine** | Docker / Docker Compose |
| **System architecture** | 64-bit only (amd64 / arm64); 32-bit systems are not supported |
### ⚙️ Environment Variable Configuration
<details>
<summary>Common environment variable configuration</summary>
| Variable Name | Description | Default Value |
|--------|------|--------|
| `SESSION_SECRET` | Authentication signing secret; must be identical on every node | - |
| `SESSION_COOKIE_SECURE` | `false`/unset disables the refresh/logout OriginGuard for local HTTP dev proxies; `true` enables the Secure cookie and strict Origin checks | `false` |
| `SESSION_COOKIE_TRUSTED_URL` | Required with Secure mode: comma-separated exact HTTPS Origins allowed to call refresh/logout; not a relay CORS allowlist | - |
| `TRUSTED_PROXIES` | Unset/blank trusts loopback, RFC 1918 and IPv6 ULA with a startup warning; `none` trusts no proxies; an explicit proxy IP/CIDR list replaces the defaults | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
| `USER_SESSION_ACTIVE_LIMIT` | Maximum active login Sessions per user | `50` |
| `USER_SESSION_ISSUANCE_LIMIT` | Maximum Sessions created per user within the issuance window, including revoked Sessions | `100` |
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Per-user Session issuance window; clamped to the revoked retention period when configured higher | `86400` |
| `USER_SESSION_REVOKED_RETENTION_DAYS` | Days to retain revoked Session rows for audit and issuance accounting | `7` |
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | Global Sessions created per hour that triggers an alert only; it never blocks login | `5000` |
| `CRYPTO_SECRET` | HMAC secret for cache keys; nodes sharing Redis must use the same effective value | Defaults to `SESSION_SECRET` |
| `SQL_DSN` | Database connection string | - |
| `REDIS_CONN_STRING` | Redis connection string | - |
| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
| `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` |
| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` |
| `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | Error log switch | `false` |
| `PYROSCOPE_URL` | Pyroscope server address | - |
| `PYROSCOPE_APP_NAME` | Pyroscope application name | `new-api` |
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope basic auth user | - |
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope basic auth password | - |
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex sampling rate | `5` |
| `PYROSCOPE_BLOCK_RATE` | Pyroscope block sampling rate | `5` |
| `HOSTNAME` | Hostname tag for Pyroscope | `new-api` |
📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables)
</details>
### 🔧 Deployment Methods
<details>
<summary><strong>Method 1: Docker Compose (Recommended)</strong></summary>
```bash ```bash
# Clone the project # Repository root
git clone https://github.com/QuantumNous/new-api.git cd web
cd new-api bun install --frozen-lockfile
bun run build
# Edit configuration cd ..
nano docker-compose.yml go run .
# Start service
docker-compose up -d
``` ```
</details> In a second terminal, start the frontend development server:
<details>
<summary><strong>Method 2: Docker Commands</strong></summary>
**Using SQLite:**
```bash ```bash
docker run --name new-api -d --restart always \ cd web
-p 3000:3000 \ bun run dev -- --port 5173
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
``` ```
**Using MySQL:** Open [http://localhost:5173](http://localhost:5173); the development server proxies API requests to the backend on port 3000. For a containerized development backend, see [docker-compose.dev.yml](./docker-compose.dev.yml) and the `make dev` target in [makefile](./makefile).
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 Path explanation:** | Location | Responsibility |
> - `./data:/data` - Relative path, data saved in the data folder of the current directory | --- | --- |
> - You can also use absolute path, e.g.: `/your/custom/path:/data` | `router/`, `middleware/`, `controller/` | HTTP routes, access checks, and API handlers |
| `relay/` | Upstream adapters and request routing |
| `service/`, `model/` | Business logic and persistence |
| [relaykit/](./relaykit/README.md) | Independently buildable Go module for protocol DTOs and conversions |
| [plugins/tasks/](./plugins/tasks/) | JavaScript task plugins; see [Task Plugin API v1](./docs/plugin-api/v1.md) for authoring and host boundaries |
| `web/` | Web console; see [frontend conventions](./web/AGENTS.md) |
| [electron/](./electron/README.md) | Desktop wrapper and packaging |
</details> 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/`.
<details> <a id="documentation"></a>
<summary><strong>Method 3: BaoTa Panel</strong></summary>
1. Install BaoTa Panel (≥ 9.2.0 version) ## Documentation and community
2. Search for **New-API** in the application store
3. One-click installation
📖 [Tutorial with images](./docs/BT.md) | Resource | Link |
| --- | --- |
| Official documentation | [Guides](https://docs.newapi.ai/en/docs) · [Installation](https://docs.newapi.ai/en/docs/installation) · [API reference](https://docs.newapi.ai/en/docs/api) |
| Project exploration | [DeepWiki](https://deepwiki.com/QuantumNous/new-api) |
| Questions and discussion | [FAQ](https://docs.newapi.ai/en/docs/support/faq) · [Community](https://docs.newapi.ai/en/docs/support/community-interaction) |
| Bugs and feature requests | [GitHub Issues](https://github.com/QuantumNous/new-api/issues) |
| Security reports | Follow the [security policy](./.github/SECURITY.md) for private reporting |
</details> For bug reports, include the version, deployment method, reproduction steps, and redacted logs. Documentation, translations, provider integrations, and focused regression tests are all welcome contributions.
### ⚠️ Multi-machine Deployment Considerations
> [!WARNING]
> - All nodes must use the same primary database and the same `SESSION_SECRET`; otherwise Access Tokens, refresh sessions, and temporary authentication flows cannot be verified consistently.
> - Nodes connected to the same Redis must also use the same `CRYPTO_SECRET`, or their cache-key digests will differ and shared entries cannot be reused consistently.
The database is authoritative for login Sessions and for the per-user active/issuance limits. Redis Session entries are short-lived caches whose TTL follows `SYNC_FREQUENCY` (60 seconds by default) and never exceeds the Session's remaining lifetime.
| Redis topology | Session propagation | Rate limiting |
| --- | --- | --- |
| Shared Redis | Revocations and version publications normally propagate immediately | Redis limits are shared across nodes |
| Independent Redis per node | Nodes converge from the database within the effective `SYNC_FREQUENCY`; a newly rotated token may receive a temporary 401 on a node with stale cache | Each node has its own allowance, so aggregate capacity can reach roughly the configured limit multiplied by the node count |
| No Redis | Every Session validation reads the database | In-memory limits are independent per node |
A shorter `SYNC_FREQUENCY` reduces the independent-Redis staleness window but causes one additional primary-key Session lookup per active SID, per node, per TTL. These guarantees make Session authentication bounded-stale across the supported topologies; rate limits and other Redis-backed control-plane caches remain topology-dependent.
See [User authentication and login sessions](./docs/authentication.md) for the token, Origin-check and PAT contracts.
### 🔄 Channel Retry and Cache
**Retry configuration:** `Settings → Operation Settings → General Settings → Failure Retry Count`
**Cache configuration:**
- `REDIS_CONN_STRING`: Redis cache (recommended)
- `MEMORY_CACHE_ENABLED`: Memory cache
--- ---
@@ -446,25 +322,23 @@ See [User authentication and login sessions](./docs/authentication.md) for the t
--- ---
## 💬 Help Support ## 📜 License
### 📖 Documentation Resources This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
| Resource | Link | Additional terms under AGPLv3 Section 7 apply. Modified versions must preserve
|------|------| the author attribution notice `Frontend design and development by New API
| 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) | contributors.` in the appropriate legal notices and in any prominent about,
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) | legal, footer, or attribution location presented by the user interface.
| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/en/docs/support/feedback-issues) |
| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/en/docs) |
### 🤝 Contribution Guide Modified versions that present a user interface must also preserve a visible
link to the original project: <https://github.com/QuantumNous/new-api>.
Welcome all forms of contribution! This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api) (MIT License).
- 🐛 Report Bugs If your organization's policies do not permit the use of AGPLv3-licensed software, or if you wish to avoid the open-source obligations of AGPLv3, please contact us at: [support@quantumnous.com](mailto:support@quantumnous.com)
- 💡 Propose New Features
- 📝 Improve Documentation See [NOTICE](./NOTICE) and [third-party licenses](./THIRD-PARTY-LICENSES.md) for attribution and dependency notices.
- 🔧 Submit Code
--- ---
@@ -484,7 +358,7 @@ Welcome all forms of contribution!
If this project is helpful to you, welcome to give us a ⭐️ Star! If this project is helpful to you, welcome to give us a ⭐️ Star!
**[Official Documentation](https://docs.newapi.pro/en/docs)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)** **[Official Documentation](https://docs.newapi.ai/en/docs)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Built with ❤️ by QuantumNous</sub> <sub>Built with ❤️ by QuantumNous</sub>
+156 -318
View File
@@ -4,7 +4,7 @@
# New API # New API
🍥 **Passerelle de modèles étendus de nouvelle génération et système de gestion d'actifs d'IA** **Une passerelle IA pour les modèles, les applications et les agents**
<p align="center"> <p align="center">
<a href="./README.zh_CN.md">简体中文</a> | <a href="./README.zh_CN.md">简体中文</a> |
@@ -44,23 +44,34 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#-démarrage-rapide">Démarrage rapide</a> • <a href="#capabilities">Fonctionnalités</a> •
<a href="#-fonctionnalités-clés">Fonctionnalités clés</a> • <a href="#quick-start">Démarrage rapide</a> •
<a href="#-déploiement">Déploiement</a> • <a href="#deployment">Déploiement</a> •
<a href="#-documentation">Documentation</a> • <a href="#development">Développement</a> •
<a href="#-aide-support">Aide</a> <a href="#documentation">Documentation</a>
</p> </p>
</div> </div>
---
## 📝 Description du projet ## 📝 Description du projet
New API est une passerelle IA auto-hébergée pour les applications, les agents et les équipes. Connectez vos fournisseurs de modèles, exposez une API commune à vos clients et gérez le routage, les accès, les usages et les coûts depuis une même console.
Utilisez-la pour partager des accès autorisés au sein d'une équipe, changer de fournisseur sans reconfigurer chaque client ou exploiter un service privé multi-modèles. Les fournisseurs incluent OpenAI, Anthropic, Google Gemini, Azure OpenAI, AWS Bedrock, Vertex AI, DeepSeek, Qwen et d'autres services compatibles.
> [!IMPORTANT] > [!IMPORTANT]
> - Ce projet est exclusivement destiné aux scénarios de passerelle API d'IA légalement autorisés, d'authentification organisationnelle, de gestion multi-modèles, d'analyse d'utilisation, de comptabilisation des coûts et de déploiement privé. > - Ce projet est exclusivement destiné aux scénarios de passerelle API d'IA légalement autorisés, d'authentification organisationnelle, de gestion multi-modèles, d'analyse d'utilisation, de comptabilisation des coûts et de déploiement privé.
> - Les utilisateurs doivent obtenir légalement les clés API, comptes, services de modèles et autorisations d'interface en amont, et doivent respecter les conditions d'utilisation en amont et les lois et réglementations applicables. > - Les utilisateurs doivent obtenir légalement les clés API, comptes, services de modèles et autorisations d'interface en amont, et doivent respecter les conditions d'utilisation en amont et les lois et réglementations applicables.
> - Les utilisateurs doivent s'assurer que leur utilisation est conforme aux conditions d'utilisation en amont et aux lois et réglementations applicables. > - Les utilisateurs doivent s'assurer que leur utilisation est conforme aux conditions d'utilisation en amont et aux lois et réglementations applicables.
> - Lors de la fourniture de services d'IA générative au public, les utilisateurs doivent se conformer aux exigences réglementaires applicables et remplir toutes les obligations d'enregistrement, de licence, de sécurité du contenu, de vérification d'identité, de conservation des journaux, de fiscalité et d'autorisation en amont requises par leur juridiction. > - Lors de la fourniture de services d'IA générative au public, les utilisateurs doivent se conformer aux exigences réglementaires applicables et remplir toutes les obligations d'enregistrement, de licence, de sécurité du contenu, de vérification d'identité, de conservation des journaux, de fiscalité et d'autorisation en amont requises par leur juridiction.
<!-- -->
> [!WARNING]
> Lorsque vous exploitez ce projet en tant que service public d'IA générative ou service de revente d'API, les utilisateurs doivent d'abord remplir toutes les obligations requises en matière d'enregistrement, de licence, de sécurité du contenu, de vérification d'identité, de conservation des journaux, de fiscalité, de paiement et d'autorisation en amont.
--- ---
## 🤝 Partenaires de confiance ## 🤝 Partenaires de confiance
@@ -79,9 +90,6 @@
--><a href="https://bda.pku.edu.cn/" target="_blank"> --><a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="Université de Pékin" height="80" /> <img src="./docs/images/pku.png" alt="Université de Pékin" height="80" />
</a><!-- </a><!--
--><a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
</a><!--
--><a href="https://www.aliyun.com/" target="_blank"> --><a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" /> <img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
</a><!-- </a><!--
@@ -106,326 +114,174 @@
--- ---
## 🚀 Démarrage rapide <a id="capabilities"></a>
### Utilisation de Docker Compose (recommandé) ## Fonctionnalités
| Domaine | Possibilités |
| --- | --- |
| Accès aux modèles | OpenAI Chat Completions, Responses, Anthropic Messages et Gemini ; streaming, outils, raisonnement et entrées multimodales selon le fournisseur |
| Routage | Correspondance des noms de modèles, priorités et poids des canaux, tentatives supplémentaires, affinité de canal et gestion de plusieurs clés |
| Usages et coûts | Quotas, abonnements, journaux d'utilisation, comptabilisation du cache et tarification par paliers fondée sur des expressions |
| Contrôle d'accès | Utilisateurs, groupes, permissions fines et restrictions des clés API ; OAuth/OIDC, clés d'accès, double authentification et gestion des sessions |
| Tâches asynchrones | Extensions JavaScript pour les API de tâches d'image, de vidéo et autres, avec suivi d'état et récupération des résultats |
| Console web | Gestion des canaux et modèles, journaux d'utilisation et d'audit, playground ; interface en anglais, chinois simplifié et traditionnel, français, japonais, russe et vietnamien |
### Protocoles et points d'accès
| Interface | Points d'accès courants |
| --- | --- |
| OpenAI Chat / Responses | `POST /v1/chat/completions`, `POST /v1/responses` |
| Anthropic Messages | `POST /v1/messages` |
| Gemini | `POST /v1beta/models/{model}:generateContent`, `POST /v1beta/models/{model}:streamGenerateContent` |
| Realtime / Responses WebSocket | `GET /v1/realtime`, `GET /v1/responses` (mise à niveau WebSocket) |
| Images / audio | `/v1/images/generations`, `/v1/images/edits`, `/v1/audio/speech`, `/v1/audio/transcriptions`, `/v1/audio/translations` |
| Embeddings / rerank | `POST /v1/embeddings`, `POST /v1/rerank` |
| Extensions de tâches | `POST /v1/tasks/{pluginKey}`, `GET /v1/tasks/{taskId}`, ainsi que les routes déclarées par chaque extension |
[RelayKit](./relaykit/README.md) convertit les requêtes, réponses et flux entre les quatre protocoles textuels. Les capacités disponibles dépendent du canal, du modèle amont et du chemin de conversion ; certains outils et champs propres à un protocole ne sont pas entièrement transposables. WebSocket nécessite également un fournisseur et une configuration de canal compatibles.
Ce README décrit le code source actuel. Consultez les notes de la version que vous déployez.
<a id="quick-start"></a>
## Démarrage rapide
### Essai local avec Docker
Cette commande démarre une instance SQLite accessible uniquement depuis la machine locale :
```bash
mkdir -p data
docker run --name new-api -d --restart unless-stopped \
-p 127.0.0.1:3000:3000 \
-e TZ=Asia/Shanghai \
-v "$(pwd)/data:/data" \
calciumion/new-api:latest
```
Ouvrez [http://localhost:3000](http://localhost:3000) et suivez l'assistant pour créer le compte administrateur. Le répertoire `data` conserve la base SQLite lors du remplacement du conteneur.
### Première requête
1. Ajoutez un canal avec votre clé API amont, ses modèles et son groupe, puis lancez un test du canal.
2. Configurez les prix des modèles et vérifiez que l'utilisateur dispose d'un quota ou d'un abonnement valide.
3. Créez une clé API dans la console, autorisée à accéder au même groupe et aux mêmes modèles.
4. Pour un client compatible OpenAI, utilisez `http://localhost:3000/v1` comme URL de base et la **clé émise par New API**.
Définissez `NEW_API_KEY` dans votre shell avec cette clé, puis listez les modèles accessibles :
```bash
curl --fail-with-body http://localhost:3000/v1/models \
-H "Authorization: Bearer ${NEW_API_KEY}"
```
Appelez ensuite Responses en remplaçant `your-enabled-model` par un modèle activé qui prend en charge cette interface :
```bash
curl --fail-with-body http://localhost:3000/v1/responses \
-H "Authorization: Bearer ${NEW_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"model":"your-enabled-model","input":"Hello!"}'
```
<a id="deployment"></a>
## Déploiement
### Docker Compose
La [configuration Compose](./docker-compose.yml) du dépôt démarre **New API + PostgreSQL + Redis** par défaut. Elle contient aussi des exemples pour MySQL et une base de journaux ClickHouse distincte.
```bash ```bash
# Cloner le projet
git clone https://github.com/QuantumNous/new-api.git git clone https://github.com/QuantumNous/new-api.git
cd new-api cd new-api
# Modifier la configuration docker-compose.yml
nano docker-compose.yml
# Démarrer le service
docker-compose up -d
``` ```
<details> Avant de démarrer, modifiez `docker-compose.yml` : remplacez les mots de passe d'exemple de la base et de Redis dans les services et les chaînes de connexion, puis définissez un `SESSION_SECRET` aléatoire et persistant (générable avec `openssl rand -hex 32`). Pour une console HTTPS, définissez `SESSION_COOKIE_SECURE=true` et renseignez son origine HTTPS publique exacte dans `SESSION_COOKIE_TRUSTED_URL`.
<summary><strong>Utilisation des commandes Docker</strong></summary>
```bash ```bash
# Tirer la dernière image docker compose up -d
docker pull calciumion/new-api:latest docker compose logs -f new-api
# Utilisation de SQLite (par défaut)
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# Utilisation de MySQL
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
``` ```
> **💡 Astuce:** `-v ./data:/data` sauvegardera les données dans le dossier `data` du répertoire actuel, vous pouvez également le changer en chemin absolu comme `-v /your/custom/path:/data` ### Stockage et configuration
</details> | Composant | Options |
| --- | --- |
| Base principale | SQLite, MySQL ≥ 5.7.8 ou PostgreSQL ≥ 9.6 |
| Base de journaux distincte | Configurée avec `LOG_SQL_DSN` ; prend aussi en charge ClickHouse |
| Cache | Redis facultatif et cache mémoire ; partagez Redis si les nœuds doivent partager les limites de débit |
| Plateformes des conteneurs | Linux amd64 / arm64 |
--- | Variable | Rôle |
| --- | --- |
| `SQL_DSN` | Connexion à la base principale ; SQLite si non définie |
| `LOG_SQL_DSN` | Connexion facultative à une base de journaux distincte |
| `REDIS_CONN_STRING` | Chaîne de connexion Redis |
| `SESSION_SECRET` | Secret d'authentification persistant, identique sur tous les nœuds |
| `CRYPTO_SECRET` | Vaut `SESSION_SECRET` par défaut ; même valeur effective pour les nœuds partageant Redis |
| `SESSION_COOKIE_SECURE` | `true` pour une console HTTPS ; active les cookies de renouvellement Secure et les contrôles stricts d'origine du renouvellement et de la déconnexion |
| `SESSION_COOKIE_TRUSTED_URL` | Obligatoire en mode Secure : origines HTTPS exactes séparées par des virgules, sans chemin ni joker ; à laisser non définie en HTTP local |
| `TRUSTED_PROXIES` | IP/CIDR des proxys de confiance, ou `none` ; à configurer selon votre réseau |
🎉 Après le déploiement, visitez `http://localhost:3000` pour commencer à utiliser! Consultez l'[exemple d'environnement](./.env.example), la [référence des variables](https://docs.newapi.ai/en/docs/installation/config-maintenance/environment-variables) et le [guide des sessions](./docs/authentication.md). Injectez les variables du conteneur avec `environment` ou `env_file` dans Compose ; copier `.env.example` ne suffit pas.
> [!WARNING] En production, utilisez HTTPS et configurez le proxy inverse pour le streaming et WebSocket. Conservez et sauvegardez les bases et les données montées. Tous les nœuds doivent partager la base principale et les secrets d'authentification ; des Redis distincts ou des limiteurs en mémoire comptent les limites séparément par nœud. Le guide des sessions détaille la propagation selon la topologie.
> Lorsque vous exploitez ce projet en tant que service public d'IA générative ou service de revente d'API, les utilisateurs doivent d'abord remplir toutes les obligations requises en matière d'enregistrement, de licence, de sécurité du contenu, de vérification d'identité, de conservation des journaux, de fiscalité, de paiement et d'autorisation en amont.
📖 Pour plus de méthodes de déploiement, veuillez vous référer à [Guide de déploiement](https://docs.newapi.pro/en/docs/installation) Choisissez une version précise d'image dans les [versions publiées](https://github.com/QuantumNous/new-api/releases), lisez ses notes et sauvegardez avant toute mise à niveau. Le tag `latest` évolue avec les publications ; évaluez les migrations et la compatibilité de votre installation.
--- <a id="development"></a>
## 📚 Documentation ## Développement et extensions
<div align="center"> Le backend utilise Go et Gin ; la console utilise React 19, TypeScript, Rsbuild, TanStack et Tailwind CSS 4. Utilisez Bun pour le frontend. Consultez [go.mod](./go.mod) pour la version de référence du langage Go et [Dockerfile](./Dockerfile) pour la chaîne de compilation du conteneur.
### 📖 [Documentation officielle](https://docs.newapi.pro/en/docs) | [![Demander à DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) Compilez le frontend avant de démarrer le backend, qui intègre `web/dist` :
</div>
**Navigation rapide:**
| Catégorie | Lien |
|------|------|
| 🚀 Guide de déploiement | [Documentation d'installation](https://docs.newapi.pro/en/docs/installation) |
| ⚙️ Configuration de l'environnement | [Variables d'environnement](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) |
| 📡 Documentation de l'API | [Documentation de l'API](https://docs.newapi.pro/en/docs/api) |
| ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/en/docs/support/community-interaction) |
---
## ✨ Fonctionnalités clés
> Pour les fonctionnalités détaillées, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction) |
### 🎨 Fonctions principales
| Fonctionnalité | Description |
|------|------|
| 🎨 Nouvelle interface utilisateur | Conception d'interface utilisateur moderne |
| 🌍 Multilingue | Prend en charge le chinois simplifié, le chinois traditionnel, l'anglais, le français et le japonais |
| 🔄 Compatibilité des données | Complètement compatible avec la base de données originale de One API |
| 📈 Tableau de bord des données | Console visuelle et analyse statistique |
| 🔒 Gestion des permissions | Regroupement de jetons, restrictions de modèles, gestion des utilisateurs |
### 💰 Comptabilisation et facturation des usages autorisés
- ✅ Rechargement interne et allocation de quotas pour les scénarios légalement autorisés (EPay, Stripe)
- ✅ Comptabilisation des coûts par requête, par utilisation et par hit de cache au niveau organisationnel
- ✅ Statistiques de facturation du cache pour OpenAI, Azure, DeepSeek, Claude, Qwen et les modèles pris en charge
- ✅ Politiques de facturation flexibles pour la gestion interne ou les clients entreprise autorisés
### 🔐 Autorisation et sécurité
- 😈 Connexion par autorisation Discord
- 🤖 Connexion par autorisation LinuxDO
- 📱 Connexion par autorisation Telegram
- 🔑 Authentification unifiée OIDC
- 🔍 Requête de quota d'utilisation de clé (avec [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
### 🚀 Fonctionnalités avancées
**Prise en charge des formats d'API:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (y compris Azure)
- ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
- ⚡ [Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
- 🔄 [Modèles Rerank](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina)
**Routage intelligent:**
- ⚖️ Sélection aléatoire pondérée des canaux
- 🔄 Nouvelle tentative automatique en cas d'échec
- 🚦 Limitation du débit du modèle pour les utilisateurs
**Conversion de format:**
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
- 🔄 **OpenAI Compatible → Google Gemini**
- 🔄 **Google Gemini → OpenAI Compatible** - Texte uniquement, les appels de fonction ne sont pas encore pris en charge
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - En développement
- 🔄 **Fonctionnalité de la pensée au contenu**
**Prise en charge de l'effort de raisonnement:**
<details>
<summary>Voir la configuration détaillée</summary>
**Modèles de la série OpenAI :**
- `o3-mini-high` - Effort de raisonnement élevé
- `o3-mini-medium` - Effort de raisonnement moyen
- `o3-mini-low` - Effort de raisonnement faible
- `gpt-5-high` - Effort de raisonnement élevé
- `gpt-5-medium` - Effort de raisonnement moyen
- `gpt-5-low` - Effort de raisonnement faible
**Modèles de pensée de Claude:**
- `claude-3-7-sonnet-20250219-thinking` - Activer le mode de pensée
**Modèles de la série Google Gemini:**
- `gemini-2.5-flash-thinking` - Activer le mode de pensée
- `gemini-2.5-flash-nothinking` - Désactiver le mode de pensée
- `gemini-2.5-pro-thinking` - Activer le mode de pensée
- `gemini-2.5-pro-thinking-128` - Activer le mode de pensée avec budget de pensée de 128 tokens
- Vous pouvez également ajouter les suffixes `-low`, `-medium` ou `-high` aux modèles Gemini pour fixer le niveau d’effort de raisonnement (sans suffixe de budget supplémentaire).
</details>
---
## 🤖 Prise en charge des modèles
> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de passerelle](https://docs.newapi.pro/en/docs/api)
| Type de modèle | Description | Documentation |
|---------|------|------|
| 🤖 OpenAI-Compatible | Modèles compatibles OpenAI | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createchatcompletion) |
| 🤖 OpenAI Responses | Format OpenAI Responses | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createresponse) |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/creatererank) |
| 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/createmessage) |
| 🌐 Gemini | Format Google Gemini | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
| 🔧 Dify | Mode ChatFlow | - |
| 🎯 Amont personnalisé | Configuration des points d'accès amont légalement autorisés | - |
### 📡 Interfaces prises en charge
<details>
<summary>Voir la liste complète des interfaces</summary>
- [Interface de discussion (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createchatcompletion)
- [Interface de réponse (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createresponse)
- [Interface d'image (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/post-v1-images-generations)
- [Interface audio (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
- [Interface vidéo (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/sora/createvideo)
- [Interface d'incorporation (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/createembedding)
- [Interface de rerank (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/creatererank)
- [Conversation en temps réel (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/createrealtimesession)
- [Discussion Claude](https://docs.newapi.pro/en/docs/api/ai-model/chat/createmessage)
- [Discussion Google Gemini](https://docs.newapi.pro/en/docs/api/ai-model/chat/gemini/geminirelayv1beta)
</details>
---
## 🚢 Déploiement
> [!TIP]
> **Dernière image Docker:** `calciumion/new-api:latest`
### 📋 Exigences de déploiement
| Composant | Exigence |
|------|------|
| **Base de données locale** | SQLite (Docker doit monter le répertoire `/data`)|
| **Base de données distante | MySQL ≥ 5.7.8 ou PostgreSQL ≥ 9.6 |
| **Moteur de conteneur** | Docker / Docker Compose |
| **Architecture système** | 64 bits uniquement (amd64 / arm64) ; les systèmes 32 bits ne sont pas pris en charge |
### ⚙️ Configuration des variables d'environnement
<details>
<summary>Configuration courante des variables d'environnement</summary>
| Nom de variable | Description | Valeur par défaut |
|--------|------|--------|
| `SESSION_SECRET` | Secret de signature d’authentification, identique sur tous les nœuds | - |
| `SESSION_COOKIE_SECURE` | `false`/non défini désactive l’OriginGuard de refresh/logout pour les proxys HTTP locaux ; `true` active le cookie Secure et le contrôle strict de l’Origin | `false` |
| `SESSION_COOKIE_TRUSTED_URL` | Obligatoire en mode Secure : Origins HTTPS exactes autorisées pour refresh/logout, séparées par des virgules ; ce n’est pas une liste CORS relay | - |
| `TRUSTED_PROXIES` | Variable absente/vide : approuve le bouclage, les réseaux RFC 1918 et l’ULA IPv6 avec un avertissement au démarrage ; `none` n’approuve aucun proxy ; une liste IP/CIDR explicite remplace les valeurs par défaut | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
| `USER_SESSION_ACTIVE_LIMIT` | Nombre maximal de Sessions de connexion actives par utilisateur | `50` |
| `USER_SESSION_ISSUANCE_LIMIT` | Nombre maximal de Sessions créées par utilisateur dans la fenêtre, y compris les Sessions révoquées | `100` |
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Fenêtre de comptage des Sessions ; limitée à la durée de conservation des Sessions révoquées si elle est supérieure | `86400` |
| `USER_SESSION_REVOKED_RETENTION_DAYS` | Conservation en jours des Sessions révoquées pour l’audit et le comptage | `7` |
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | Seuil global horaire déclenchant uniquement une alerte, sans bloquer les connexions | `5000` |
| `CRYPTO_SECRET` | Secret HMAC des clés de cache ; les nœuds partageant Redis doivent utiliser la même valeur effective | Par défaut, `SESSION_SECRET` |
| `SQL_DSN` | Chaine de connexion à la base de données | - |
| `REDIS_CONN_STRING` | Chaine de connexion Redis | - |
| `STREAMING_TIMEOUT` | Délai d'expiration du streaming (secondes) | `300` |
| `STREAM_SCANNER_MAX_BUFFER_MB` | Taille max du buffer par ligne (Mo) pour le scanner SSE ; à augmenter quand les sorties image/base64 sont très volumineuses (ex. images 4K) | `64` |
| `MAX_REQUEST_BODY_MB` | Taille maximale du corps de requête (Mo, comptée **après décompression** ; évite les requêtes énormes/zip bombs qui saturent la mémoire). Dépassement ⇒ `413` | `32` |
| `AZURE_DEFAULT_API_VERSION` | Version de l'API Azure | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | Interrupteur du journal d'erreurs | `false` |
| `PYROSCOPE_URL` | Adresse du serveur Pyroscope | - |
| `PYROSCOPE_APP_NAME` | Nom de l'application Pyroscope | `new-api` |
| `PYROSCOPE_BASIC_AUTH_USER` | Utilisateur Basic Auth Pyroscope | - |
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Mot de passe Basic Auth Pyroscope | - |
| `PYROSCOPE_MUTEX_RATE` | Taux d'échantillonnage mutex Pyroscope | `5` |
| `PYROSCOPE_BLOCK_RATE` | Taux d'échantillonnage block Pyroscope | `5` |
| `HOSTNAME` | Nom d'hôte tagué pour Pyroscope | `new-api` |
📖 **Configuration complète:** [Documentation des variables d'environnement](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables)
</details>
### 🔧 Méthodes de déploiement
<details>
<summary><strong>Méthode 1: Docker Compose (recommandé)</strong></summary>
```bash ```bash
# Cloner le projet # Racine du dépôt
git clone https://github.com/QuantumNous/new-api.git cd web
cd new-api bun install --frozen-lockfile
bun run build
# Modifier la configuration cd ..
nano docker-compose.yml go run .
# Démarrer le service
docker-compose up -d
``` ```
</details> Dans un second terminal, démarrez le serveur de développement frontend :
<details>
<summary><strong>Méthode 2: Commandes Docker</strong></summary>
**Utilisation de SQLite:**
```bash ```bash
docker run --name new-api -d --restart always \ cd web
-p 3000:3000 \ bun run dev -- --port 5173
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
``` ```
**Utilisation de MySQL:** Ouvrez [http://localhost:5173](http://localhost:5173) ; les requêtes API sont relayées vers le backend sur le port 3000. Pour un backend de développement en conteneur, consultez [docker-compose.dev.yml](./docker-compose.dev.yml) et la cible `make dev` du [makefile](./makefile).
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 Explication du chemin:** | Emplacement | Responsabilité |
> - `./data:/data` - Chemin relatif, données sauvegardées dans le dossier data du répertoire actuel | --- | --- |
> - Vous pouvez également utiliser un chemin absolu, par exemple : `/your/custom/path:/data` | `router/`, `middleware/`, `controller/` | Routes HTTP, contrôles d'accès et gestionnaires API |
| `relay/` | Adaptateurs amont et routage des requêtes |
| `service/`, `model/` | Logique métier et persistance |
| [relaykit/](./relaykit/README.md) | Module Go autonome pour les DTO et conversions de protocoles |
| [plugins/tasks/](./plugins/tasks/) | Extensions de tâches JavaScript ; contrat et limites de l'hôte dans [Task Plugin API v1](./docs/plugin-api/v1.md) |
| `web/` | Console web ; voir les [conventions frontend](./web/AGENTS.md) |
| [electron/](./electron/README.md) | Application de bureau et packaging |
</details> 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/`.
<details> <a id="documentation"></a>
<summary><strong>Méthode 3: Panneau BaoTa</strong></summary>
1. Installez le panneau BaoTa (version ≥ 9.2.0) ## Documentation et communauté
2. Recherchez **New-API** dans le magasin d'applications
3. Installation en un clic
📖 [Tutoriel avec des images](./docs/BT.md) | Ressource | Lien |
| --- | --- |
| Documentation officielle | [Guides](https://docs.newapi.ai/en/docs) · [Installation](https://docs.newapi.ai/en/docs/installation) · [Référence API](https://docs.newapi.ai/en/docs/api) |
| Exploration du projet | [DeepWiki](https://deepwiki.com/QuantumNous/new-api) |
| Questions et échanges | [FAQ](https://docs.newapi.ai/en/docs/support/faq) · [Communauté](https://docs.newapi.ai/en/docs/support/community-interaction) |
| Bugs et propositions | [GitHub Issues](https://github.com/QuantumNous/new-api/issues) |
| Vulnérabilités | Signalement privé selon la [politique de sécurité](./.github/SECURITY.md) |
</details> Pour signaler un bug, indiquez la version, le déploiement, les étapes de reproduction et des journaux expurgés des données sensibles. Les contributions à la documentation, aux traductions, aux intégrations et aux tests de régression ciblés sont les bienvenues.
### ⚠️ Considérations sur le déploiement multi-machines
> [!WARNING]
> - Tous les nœuds doivent utiliser la même base de données principale et la même valeur `SESSION_SECRET` ; sinon les Access Tokens, sessions Refresh et flux d’authentification temporaires ne peuvent pas être vérifiés de façon cohérente.
> - Les nœuds connectés au même Redis doivent aussi utiliser le même `CRYPTO_SECRET`, faute de quoi les empreintes de clé de cache diffèrent et les entrées partagées ne peuvent pas être réutilisées de façon cohérente.
La base de données fait autorité pour les Sessions de connexion et pour les limites actives/d’émission par utilisateur. Les entrées Session de Redis sont des caches de courte durée dont le TTL suit `SYNC_FREQUENCY` (60 secondes par défaut), sans jamais dépasser la durée de vie restante de la Session.
| Topologie Redis | Propagation des Sessions | Limitation de débit |
| --- | --- | --- |
| Redis partagé | Les révocations et publications de version se propagent normalement immédiatement | Les quotas Redis sont partagés entre les nœuds |
| Redis indépendant par nœud | Les nœuds se resynchronisent depuis la base dans le délai effectif de `SYNC_FREQUENCY` ; un nouveau Token issu d’une rotation peut recevoir temporairement une réponse 401 sur un nœud dont le cache est obsolète | Chaque nœud possède son propre quota ; la capacité agrégée peut donc atteindre environ la limite configurée multipliée par le nombre de nœuds |
| Sans Redis | Chaque validation de Session consulte directement la base de données | Les limites en mémoire sont indépendantes sur chaque nœud |
Réduire `SYNC_FREQUENCY` raccourcit la fenêtre d’obsolescence avec des Redis indépendants, mais ajoute une lecture de Session par clé primaire, par SID actif, par nœud et par TTL. Ces garanties donnent une obsolescence bornée à l’authentification Session ; les limites et les autres caches du plan de contrôle adossés à Redis restent dépendants de la topologie.
Consultez [Authentification utilisateur et sessions de connexion](./docs/authentication.md) pour les contrats de token, de vérification Origin et de PAT.
### 🔄 Nouvelle tentative de canal et cache
**Configuration de la nouvelle tentative:** `Paramètres → Paramètres de fonctionnement → Paramètres généraux → Nombre de tentatives en cas d'échec`
**Configuration du cache:**
- `REDIS_CONN_STRING`: Cache Redis (recommandé)
- `MEMORY_CACHE_ENABLED`: Cache mémoire
--- ---
@@ -447,36 +303,18 @@ Consultez [Authentification utilisateur et sessions de connexion](./docs/authent
--- ---
## 💬 Aide et support
### 📖 Ressources de documentation
| Ressource | Lien |
|------|------|
| 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/en/docs/support/community-interaction) |
| 🐛 Commentaires sur les problèmes | [Commentaires sur les problèmes](https://docs.newapi.pro/en/docs/support/feedback-issues) |
| 📚 Documentation complète | [Documentation officielle](https://docs.newapi.pro/en/docs) |
### 🤝 Guide de contribution
Bienvenue à toutes les formes de contribution!
- 🐛 Signaler des bogues
- 💡 Proposer de nouvelles fonctionnalités
- 📝 Améliorer la documentation
- 🔧 Soumettre du code
---
## 📜 Licence ## 📜 Licence
Ce projet est sous licence [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE). Ce projet est sous licence [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
Des [conditions supplémentaires](./NOTICE) s'appliquent au titre de la section 7 de l'AGPLv3. Les versions modifiées doivent conserver la mention `Frontend design and development by New API contributors.` dans les mentions légales appropriées et les emplacements visibles de l'interface dédiés aux informations, au droit, au pied de page ou aux attributions, ainsi qu'un lien visible vers le projet original : <https://github.com/QuantumNous/new-api>.
Il s'agit d'un projet open-source développé sur la base de [One API](https://github.com/songquanpeng/one-api) (licence MIT). Il s'agit d'un projet open-source développé sur la base de [One API](https://github.com/songquanpeng/one-api) (licence MIT).
Si les politiques de votre organisation ne permettent pas l'utilisation de logiciels sous licence AGPLv3, ou si vous souhaitez éviter les obligations open-source de l'AGPLv3, veuillez nous contacter à : [support@quantumnous.com](mailto:support@quantumnous.com) Si les politiques de votre organisation ne permettent pas l'utilisation de logiciels sous licence AGPLv3, ou si vous souhaitez éviter les obligations open-source de l'AGPLv3, veuillez nous contacter à : [support@quantumnous.com](mailto:support@quantumnous.com)
Consultez [NOTICE](./NOTICE) et les [licences tierces](./THIRD-PARTY-LICENSES.md) pour les attributions et les dépendances.
--- ---
## 🌟 Historique des étoiles ## 🌟 Historique des étoiles
@@ -495,7 +333,7 @@ Si les politiques de votre organisation ne permettent pas l'utilisation de logic
Si ce projet vous est utile, bienvenue à nous donner une ⭐️ Étoile! Si ce projet vous est utile, bienvenue à nous donner une ⭐️ Étoile!
**[Documentation officielle](https://docs.newapi.pro/en/docs)** • **[Commentaires sur les problèmes](https://github.com/Calcium-Ion/new-api/issues)** • **[Dernière version](https://github.com/Calcium-Ion/new-api/releases)** **[Documentation officielle](https://docs.newapi.ai/en/docs)** • **[Commentaires sur les problèmes](https://github.com/Calcium-Ion/new-api/issues)** • **[Dernière version](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Construit avec ❤️ par QuantumNous</sub> <sub>Construit avec ❤️ par QuantumNous</sub>
+156 -318
View File
@@ -4,7 +4,7 @@
# New API # New API
🍥 **次世代大規模モデルゲートウェイとAI資産管理システム** **モデル、アプリケーション、Agent をつなぐ AI ゲートウェイ**
<p align="center"> <p align="center">
<a href="./README.zh_CN.md">简体中文</a> | <a href="./README.zh_CN.md">简体中文</a> |
@@ -44,23 +44,34 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#-クイックスタート">クイックスタート</a> • <a href="#capabilities">主な機能</a> •
<a href="#-主な機能">主な機能</a> • <a href="#quick-start">クイックスタート</a> •
<a href="#-デプロイ">デプロイ</a> • <a href="#deployment">デプロイ</a> •
<a href="#-ドキュメント">ドキュメント</a> • <a href="#development">開発</a> •
<a href="#-ヘルプサポート">ヘルプ</a> <a href="#documentation">ドキュメント</a>
</p> </p>
</div> </div>
---
## 📝 プロジェクト説明 ## 📝 プロジェクト説明
New API は、アプリケーション、Agent、チーム向けのセルフホスト型 AI ゲートウェイです。各社のモデルサービスを共通の入口に接続し、ルーティング、アクセス権、使用量、コストを一つの管理画面で扱えます。
チーム内で許可済みのモデルを共有する、クライアントの再設定を減らしてプロバイダーを切り替える、複数モデルを扱うプライベートサービスを構築するといった用途に使えます。OpenAI、Anthropic、Google Gemini、Azure OpenAI、AWS Bedrock、Vertex AI、DeepSeek、Qwen などに対応しています。
> [!IMPORTANT] > [!IMPORTANT]
> - 本プロジェクトは、合法的に許可された AI API ゲートウェイ、組織レベルの認証、マルチモデル管理、利用量分析、コスト管理、プライベートデプロイのシナリオのみを対象としています。 > - 本プロジェクトは、合法的に許可された AI API ゲートウェイ、組織レベルの認証、マルチモデル管理、利用量分析、コスト管理、プライベートデプロイのシナリオのみを対象としています。
> - ユーザーは、上流の API キー、アカウント、モデルサービス、インターフェース権限を合法的に取得し、上流のサービス利用規約および適用される法律法規を遵守する必要があります。 > - ユーザーは、上流の API キー、アカウント、モデルサービス、インターフェース権限を合法的に取得し、上流のサービス利用規約および適用される法律法規を遵守する必要があります。
> - ユーザーは、利用方法が上流のサービス利用規約および適用される法律法規に準拠していることを確認してください。 > - ユーザーは、利用方法が上流のサービス利用規約および適用される法律法規に準拠していることを確認してください。
> - 生成 AI サービスを公衆に提供する場合、ユーザーは適用される規制要件を遵守し、管轄区域で求められる届出、ライセンス、コンテンツセキュリティ、本人確認、ログ保持、税務、上流認可などのすべての義務を履行してください。 > - 生成 AI サービスを公衆に提供する場合、ユーザーは適用される規制要件を遵守し、管轄区域で求められる届出、ライセンス、コンテンツセキュリティ、本人確認、ログ保持、税務、上流認可などのすべての義務を履行してください。
<!-- -->
> [!WARNING]
> 本プロジェクトを公衆向け生成 AI サービスまたは API 再販サービスとして運営する場合、ユーザーは届出、コンテンツセキュリティ、本人確認、ログ保持、税務、決済、上流認可などの必要なコンプライアンス義務を先に完了してください。
--- ---
## 🤝 信頼できるパートナー ## 🤝 信頼できるパートナー
@@ -79,9 +90,6 @@
--><a href="https://bda.pku.edu.cn/" target="_blank"> --><a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="北京大学" height="80" /> <img src="./docs/images/pku.png" alt="北京大学" height="80" />
</a><!-- </a><!--
--><a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="80" />
</a><!--
--><a href="https://www.aliyun.com/" target="_blank"> --><a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" /> <img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
</a><!-- </a><!--
@@ -106,326 +114,174 @@
--- ---
## 🚀 クイックスタート <a id="capabilities"></a>
### Docker Composeを使用(推奨) ## 主な機能
| 分野 | できること |
| --- | --- |
| モデル接続 | OpenAI Chat Completions、Responses、Anthropic Messages、Gemini に対応。上流が対応するストリーミング、ツール呼び出し、推論、マルチモーダル入力を利用可能 |
| ルーティング | モデル名のマッピング、チャネルの優先度と重み、再試行、チャネルアフィニティ、複数キーの管理 |
| 使用量とコスト | クォータ、サブスクリプション、使用ログ、キャッシュの課金、式による段階的な料金設定 |
| アクセス制御 | ユーザー、グループ、詳細な権限、API キーの制限。OAuth/OIDC、パスキー、二要素認証、ログインセッション管理 |
| 非同期タスク | JavaScript プラグインで画像・動画などのタスク API を拡張し、状態の確認と成果物の取得に対応 |
| Web 管理画面 | チャネルとモデルの設定、使用・監査ログの確認、Playground でのモデル検証。英語、簡体字中国語、繁体字中国語、フランス語、日本語、ロシア語、ベトナム語に対応 |
### プロトコルとエンドポイント
| インターフェース | 主なエンドポイント |
| --- | --- |
| OpenAI Chat / Responses | `POST /v1/chat/completions`、`POST /v1/responses` |
| Anthropic Messages | `POST /v1/messages` |
| Gemini | `POST /v1beta/models/{model}:generateContent`、`POST /v1beta/models/{model}:streamGenerateContent` |
| Realtime / Responses WebSocket | `GET /v1/realtime`、`GET /v1/responses`(WebSocket アップグレード) |
| 画像 / 音声 | `/v1/images/generations`、`/v1/images/edits`、`/v1/audio/speech`、`/v1/audio/transcriptions`、`/v1/audio/translations` |
| 埋め込み / リランク | `POST /v1/embeddings`、`POST /v1/rerank` |
| タスクプラグイン | `POST /v1/tasks/{pluginKey}`、`GET /v1/tasks/{taskId}`、各プラグインが宣言するルート |
[RelayKit](./relaykit/README.md) は、上記 4 種類のテキストプロトコル間でリクエスト、レスポンス、ストリームを変換します。利用できる機能はチャネル、上流モデル、変換経路に依存し、固有のツールやフィールドを完全には変換できない場合があります。WebSocket も対応する上流とチャネル設定が必要です。
この README は現在のソースコードを説明しています。デプロイするバージョンのリリースノートも確認してください。
<a id="quick-start"></a>
## クイックスタート
### Docker でローカル実行
SQLite を使う単一インスタンスを、ローカルホスト限定で起動します。
```bash
mkdir -p data
docker run --name new-api -d --restart unless-stopped \
-p 127.0.0.1:3000:3000 \
-e TZ=Asia/Shanghai \
-v "$(pwd)/data:/data" \
calciumion/new-api:latest
```
[http://localhost:3000](http://localhost:3000) を開き、初期設定ウィザードで管理者アカウントを作成します。SQLite データベースはマウントした `data` ディレクトリに保存され、コンテナを置き換えても残ります。
### 最初のリクエスト
1. 上流の API キー、利用するモデル、グループを設定してチャネルを追加し、チャネルテストを実行します。
2. モデルの料金を設定し、ユーザーに利用可能なクォータまたは有効なサブスクリプションがあることを確認します。
3. 管理画面で、同じグループとモデルにアクセスできる API キーを作成します。
4. OpenAI 互換クライアントでは Base URL を `http://localhost:3000/v1` に設定し、**New API が発行したキー**を使います。
シェルの `NEW_API_KEY` にそのキーを設定し、アクセス可能なモデルを確認します。
```bash
curl --fail-with-body http://localhost:3000/v1/models \
-H "Authorization: Bearer ${NEW_API_KEY}"
```
次に Responses を呼び出します。`your-enabled-model` は、このインターフェースに対応する有効なモデル名に置き換えてください。
```bash
curl --fail-with-body http://localhost:3000/v1/responses \
-H "Authorization: Bearer ${NEW_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"model":"your-enabled-model","input":"Hello!"}'
```
<a id="deployment"></a>
## デプロイと運用
### Docker Compose
リポジトリの [Compose 設定](./docker-compose.yml) は、既定で **New API + PostgreSQL + Redis** を起動します。MySQL と独立した ClickHouse ログデータベースの設定例も含まれます。
```bash ```bash
# プロジェクトをクローン
git clone https://github.com/QuantumNous/new-api.git git clone https://github.com/QuantumNous/new-api.git
cd new-api cd new-api
# docker-compose.yml 設定を編集
nano docker-compose.yml
# サービスを起動
docker-compose up -d
``` ```
<details> 起動前に `docker-compose.yml` を編集し、データベースと Redis のサービスおよび接続文字列にあるサンプルパスワードを変更します。永続的に使うランダムな `SESSION_SECRET` も設定してください(`openssl rand -hex 32` で生成可能)。HTTPS の管理画面には `SESSION_COOKIE_SECURE=true` を設定し、公開 HTTPS オリジンを `SESSION_COOKIE_TRUSTED_URL` に正確に指定します。
<summary><strong>Dockerコマンドを使用</strong></summary>
```bash ```bash
# 最新のイメージをプル docker compose up -d
docker pull calciumion/new-api:latest docker compose logs -f new-api
# SQLiteを使用(デフォルト)
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# MySQLを使用
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
``` ```
> **💡 ヒント:** `-v ./data:/data` は現在のディレクトリの `data` フォルダにデータを保存します。絶対パスに変更することもできます:`-v /your/custom/path:/data` ### ストレージと設定
</details> | コンポーネント | 選択肢 |
| --- | --- |
| メインデータベース | SQLite、MySQL ≥ 5.7.8、PostgreSQL ≥ 9.6 |
| 独立したログデータベース | `LOG_SQL_DSN` で設定。ClickHouse にも対応 |
| キャッシュ | 任意の Redis とメモリキャッシュ。ノード間でレート制限を共有する場合は Redis を共有 |
| コンテナのプラットフォーム | Linux amd64 / arm64 |
--- | 環境変数 | 用途 |
| --- | --- |
| `SQL_DSN` | メインデータベースへの接続。未設定なら SQLite |
| `LOG_SQL_DSN` | 任意の独立ログデータベースへの接続 |
| `REDIS_CONN_STRING` | Redis 接続文字列 |
| `SESSION_SECRET` | 永続的な認証用シークレット。全ノードで同じ値を使用 |
| `CRYPTO_SECRET` | 既定は `SESSION_SECRET`。Redis を共有するノードは同じ実効値を使用 |
| `SESSION_COOKIE_SECURE` | HTTPS の管理画面では `true`。Secure な更新用 Cookie と、更新・ログアウト時の厳格なオリジン検証を有効化 |
| `SESSION_COOKIE_TRUSTED_URL` | Secure モードでは必須。パスやワイルドカードを含まない正確な HTTPS オリジンをカンマ区切りで指定。ローカル HTTP では未設定にする |
| `TRUSTED_PROXIES` | 信頼するリバースプロキシの IP/CIDR、または `none`。実際のネットワークに合わせて明示的に設定 |
🎉 デプロイが完了したら、`http://localhost:3000` にアクセスして使用を開始してください! 詳細は[環境変数の例](./.env.example)、[環境変数リファレンス](https://docs.newapi.ai/ja/docs/installation/config-maintenance/environment-variables)、[認証とセッションの説明](./docs/authentication.md)を参照してください。コンテナの変数は Compose の `environment` または `env_file` で渡します。`.env.example` のコピーだけではコンテナに反映されません。
> [!WARNING] 本番環境では HTTPS を使い、リバースプロキシでストリーミングと WebSocket アップグレードを有効にします。データベースとマウントデータを永続化し、バックアップしてください。複数ノードはメインデータベースと認証シークレットを共有します。独立した Redis やメモリ内のレート制限はノードごとに計数します。構成別のセッション伝播については認証の説明を参照してください。
> 本プロジェクトを公衆向け生成 AI サービスまたは API 再販サービスとして運営する場合、ユーザーは届出、コンテンツセキュリティ、本人確認、ログ保持、税務、決済、上流認可などの必要なコンプライアンス義務を先に完了してください。
📖 その他のデプロイ方法については[デプロイガイド](https://docs.newapi.pro/ja/docs/installation)を参照してください。 [リリース一覧](https://github.com/QuantumNous/new-api/releases)からイメージのバージョンを固定し、更新内容を読んでバックアップしてからアップグレードしてください。`latest` は公開ビルドに応じて変わります。既存環境の移行と互換性は、利用中のバージョンに応じて確認してください。
--- <a id="development"></a>
## 📚 ドキュメント ## 開発と拡張
<div align="center"> バックエンドは Go と Gin、管理画面は React 19、TypeScript、Rsbuild、TanStack、Tailwind CSS 4 を使用します。フロントエンドの依存関係とスクリプトには Bun を使います。Go 言語の基準バージョンは [go.mod](./go.mod)、コンテナのビルドツールチェーンは [Dockerfile](./Dockerfile) を参照してください。
### 📖 [公式ドキュメント](https://docs.newapi.pro/ja/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) バックエンドは `web/dist` を埋め込むため、先にフロントエンドをビルドします。
</div>
**クイックナビゲーション:**
| カテゴリ | リンク |
|------|------|
| 🚀 デプロイガイド | [インストールドキュメント](https://docs.newapi.pro/ja/docs/installation) |
| ⚙️ 環境設定 | [環境変数](https://docs.newapi.pro/ja/docs/installation/config-maintenance/environment-variables) |
| 📡 APIドキュメント | [APIドキュメント](https://docs.newapi.pro/ja/docs/api) |
| ❓ よくある質問 | [FAQ](https://docs.newapi.pro/ja/docs/support/faq) |
| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/ja/docs/support/community-interaction) |
---
## ✨ 主な機能
> 詳細な機能については[機能説明](https://docs.newapi.pro/ja/docs/guide/wiki/basic-concepts/features-introduction)を参照してください。
### 🎨 コア機能
| 機能 | 説明 |
|------|------|
| 🎨 新しいUI | モダンなユーザーインターフェースデザイン |
| 🌍 多言語 | 簡体字中国語、繁体字中国語、英語、フランス語、日本語をサポート |
| 🔄 データ互換性 | オリジナルのOne APIデータベースと完全に互換性あり |
| 📈 データダッシュボード | ビジュアルコンソールと統計分析 |
| 🔒 権限管理 | トークングループ化、モデル制限、ユーザー管理 |
### 💰 認可済み利用量とコスト管理
- ✅ 合法的に許可されたシナリオでの内部チャージとクォータ割り当て(EPay、Stripe)
- ✅ 組織レベルのリクエスト単位、使用量ベース、キャッシュヒットのコスト会計
- ✅ OpenAI、Azure、DeepSeek、Claude、Qwen などのモデルのキャッシュ課金統計
- ✅ 内部管理または認可済み企業顧客向けの柔軟な課金ポリシー
### 🔐 認証とセキュリティ
- 😈 Discord認証ログイン
- 🤖 LinuxDO認証ログイン
- 📱 Telegram認証ログイン
- 🔑 OIDC統一認証
- 🔍 Key使用量クォータ照会([new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool)と併用)
### 🚀 高度な機能
**APIフォーマットサポート:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-response)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/create-realtime-session)(Azureを含む)
- ⚡ [Claude Messages](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message)
- ⚡ [Google Gemini](https://doc.newapi.pro/ja/api/google-gemini-chat)
- 🔄 [Rerankモデル](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank)(Cohere、Jina)
**インテリジェントルーティング:**
- ⚖️ チャネル重み付けランダム
- 🔄 失敗自動リトライ
- 🚦 ユーザーレベルモデルレート制限
**フォーマット変換:**
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
- 🔄 **OpenAI Compatible → Google Gemini**
- 🔄 **Google Gemini → OpenAI Compatible** - テキストのみ、関数呼び出しはまだサポートされていません
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - 開発中
- 🔄 **思考からコンテンツへの機能**
**Reasoning Effort サポート:**
<details>
<summary>詳細設定を表示</summary>
**OpenAIシリーズモデル:**
- `o3-mini-high` - 高思考努力
- `o3-mini-medium` - 中思考努力
- `o3-mini-low` - 低思考努力
- `gpt-5-high` - 高思考努力
- `gpt-5-medium` - 中思考努力
- `gpt-5-low` - 低思考努力
**Claude思考モデル:**
- `claude-3-7-sonnet-20250219-thinking` - 思考モードを有効にする
**Google Geminiシリーズモデル:**
- `gemini-2.5-flash-thinking` - 思考モードを有効にする
- `gemini-2.5-flash-nothinking` - 思考モードを無効にする
- `gemini-2.5-pro-thinking` - 思考モードを有効にする
- `gemini-2.5-pro-thinking-128` - 思考モードを有効にし、思考予算を128トークンに設定する
- Gemini モデル名の末尾に `-low` / `-medium` / `-high` を付けることで推論強度を直接指定できます(追加の思考予算サフィックスは不要です)。
</details>
---
## 🤖 モデルサポート
> 詳細については[APIドキュメント - ゲートウェイインターフェース](https://docs.newapi.pro/ja/docs/api)
| モデルタイプ | 説明 | ドキュメント |
|---------|------|------|
| 🤖 OpenAI-Compatible | OpenAI互換モデル | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/createchatcompletion) |
| 🤖 OpenAI Responses | OpenAI Responsesフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/createresponse) |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [ドキュメント](https://doc.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [ドキュメント](https://doc.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere、Jina | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/creatererank) |
| 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/createmessage) |
| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
| 🔧 Dify | ChatFlowモード | - |
| 🎯 カスタム上流 | 合法的に許可された上流エンドポイントの設定をサポート | - |
### 📡 サポートされているインターフェース
<details>
<summary>完全なインターフェースリストを表示</summary>
- [チャットインターフェース (Chat Completions)](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/createchatcompletion)
- [レスポンスインターフェース (Responses)](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/createresponse)
- [イメージインターフェース (Image)](https://docs.newapi.pro/ja/docs/api/ai-model/images/openai/post-v1-images-generations)
- [オーディオインターフェース (Audio)](https://docs.newapi.pro/ja/docs/api/ai-model/audio/openai/create-transcription)
- [ビデオインターフェース (Video)](https://docs.newapi.pro/ja/docs/api/ai-model/videos/sora/createvideo)
- [エンベッドインターフェース (Embeddings)](https://docs.newapi.pro/ja/docs/api/ai-model/embeddings/createembedding)
- [再ランク付けインターフェース (Rerank)](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/creatererank)
- [リアルタイム対話インターフェース (Realtime)](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/createrealtimesession)
- [Claudeチャット](https://docs.newapi.pro/ja/docs/api/ai-model/chat/createmessage)
- [Google Geminiチャット](https://docs.newapi.pro/ja/docs/api/ai-model/chat/gemini/geminirelayv1beta)
</details>
---
## 🚢 デプロイ
> [!TIP]
> **最新のDockerイメージ:** `calciumion/new-api:latest`
### 📋 デプロイ要件
| コンポーネント | 要件 |
|------|------|
| **ローカルデータベース** | SQLite(Dockerは `/data` ディレクトリをマウントする必要があります)|
| **リモートデータベース** | MySQL ≥ 5.7.8 または PostgreSQL ≥ 9.6 |
| **コンテナエンジン** | Docker / Docker Compose |
| **システムアーキテクチャ** | 64ビットのみ対応(amd64 / arm64)。32ビットシステムは非対応 |
### ⚙️ 環境変数設定
<details>
<summary>一般的な環境変数設定</summary>
| 変数名 | 説明 | デフォルト値 |
|--------|------|--------|
| `SESSION_SECRET` | 認証署名シークレット。すべてのノードで同じ値が必要 | - |
| `SESSION_COOKIE_SECURE` | `false`/未設定ではローカル HTTP 開発プロキシ向けに refresh/logout の OriginGuard を無効化し、`true` では Secure Cookie と厳格な Origin 検証を有効化 | `false` |
| `SESSION_COOKIE_TRUSTED_URL` | Secure モードでは必須。refresh/logout を許可する完全一致の HTTPS Origin をカンマ区切りで指定。relay CORS 設定ではありません | - |
| `TRUSTED_PROXIES` | 未設定/空ではループバック、RFC 1918、IPv6 ULA を信頼して起動時に警告し、`none` ではすべて無効、明示的なプロキシ IP/CIDR リストは既定値を完全に置き換えます | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
| `USER_SESSION_ACTIVE_LIMIT` | 1 ユーザーあたりの有効なログイン Session 上限 | `50` |
| `USER_SESSION_ISSUANCE_LIMIT` | カウント期間内に作成できる Session 数の上限(取り消し済みを含む) | `100` |
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 発行のカウント期間(秒)。取り消し済み Session の保持期間を超える場合は自動的に制限 | `86400` |
| `USER_SESSION_REVOKED_RETENTION_DAYS` | 監査と発行数計算のため取り消し済み Session を保持する日数 | `7` |
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | 1 時間あたりのグローバル Session 発行数の警告閾値。ログインは拒否しません | `5000` |
| `CRYPTO_SECRET` | キャッシュキー用 HMAC シークレット。Redis を共有するノードでは同じ実効値が必要 | デフォルトは `SESSION_SECRET` |
| `SQL_DSN** | データベース接続文字列 | - |
| `REDIS_CONN_STRING` | Redis接続文字列 | - |
| `STREAMING_TIMEOUT` | ストリーミング応答のタイムアウト時間(秒) | `300` |
| `STREAM_SCANNER_MAX_BUFFER_MB` | ストリームスキャナの1行あたりバッファ上限(MB)。4K画像など巨大なbase64 `data:` ペイロードを扱う場合は値を増加させてください | `64` |
| `MAX_REQUEST_BODY_MB` | リクエストボディ最大サイズ(MB、**解凍後**に計測。巨大リクエスト/zip bomb によるメモリ枯渇を防止)。超過時は `413` | `32` |
| `AZURE_DEFAULT_API_VERSION` | Azure APIバージョン | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | エラーログスイッチ | `false` |
| `PYROSCOPE_URL` | Pyroscopeサーバーのアドレス | - |
| `PYROSCOPE_APP_NAME` | Pyroscopeアプリ名 | `new-api` |
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Authユーザー | - |
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Authパスワード | - |
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutexサンプリング率 | `5` |
| `PYROSCOPE_BLOCK_RATE` | Pyroscope blockサンプリング率 | `5` |
| `HOSTNAME` | Pyroscope用のホスト名タグ | `new-api` |
📖 **完全な設定:** [環境変数ドキュメント](https://docs.newapi.pro/ja/docs/installation/config-maintenance/environment-variables)
</details>
### 🔧 デプロイ方法
<details>
<summary><strong>方法 1: Docker Compose(推奨)</strong></summary>
```bash ```bash
# プロジェクトをクローン # リポジトリのルート
git clone https://github.com/QuantumNous/new-api.git cd web
cd new-api bun install --frozen-lockfile
bun run build
# 設定を編集 cd ..
nano docker-compose.yml go run .
# サービスを起動
docker-compose up -d
``` ```
</details> 別のターミナルでフロントエンド開発サーバーを起動します。
<details>
<summary><strong>方法 2: Dockerコマンド</strong></summary>
**SQLiteを使用:**
```bash ```bash
docker run --name new-api -d --restart always \ cd web
-p 3000:3000 \ bun run dev -- --port 5173
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
``` ```
**MySQLを使用:** [http://localhost:5173](http://localhost:5173) を開きます。API リクエストはポート 3000 のバックエンドに転送されます。開発用バックエンドをコンテナで動かす場合は [docker-compose.dev.yml](./docker-compose.dev.yml) と [makefile](./makefile) の `make dev` を参照してください。
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 パス説明:** | 場所 | 役割 |
> - `./data:/data` - 相対パス、データは現在のディレクトリのdataフォルダに保存されます | --- | --- |
> - 絶対パスを使用することもできます:`/your/custom/path:/data` | `router/`、`middleware/`、`controller/` | HTTP ルート、アクセス検証、API ハンドラー |
| `relay/` | 上流アダプターとリクエストのルーティング |
| `service/`、`model/` | ビジネスロジックと永続化 |
| [relaykit/](./relaykit/README.md) | プロトコル DTO と変換を提供する、独立してビルド可能な Go モジュール |
| [plugins/tasks/](./plugins/tasks/) | JavaScript タスクプラグイン。作成方法とホストの境界は [Task Plugin API v1](./docs/plugin-api/v1.md) を参照 |
| `web/` | Web 管理画面。[フロントエンドの規約](./web/AGENTS.md)を参照 |
| [electron/](./electron/README.md) | デスクトップアプリのラッパーとパッケージング |
</details> 貢献前に [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 ./...` も実行してください。
<details> <a id="documentation"></a>
<summary><strong>方法 3: 宝塔パネル</strong></summary>
1. 宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を検索してインストールします。 ## ドキュメントとコミュニティ
📖 [画像付きチュートリアル](./docs/BT.md) | リソース | リンク |
| --- | --- |
| 公式ドキュメント | [ガイド](https://docs.newapi.ai/ja/docs) · [インストール](https://docs.newapi.ai/ja/docs/installation) · [API リファレンス](https://docs.newapi.ai/ja/docs/api) |
| プロジェクトの解説 | [DeepWiki](https://deepwiki.com/QuantumNous/new-api) |
| 質問と交流 | [FAQ](https://docs.newapi.ai/ja/docs/support/faq) · [コミュニティ](https://docs.newapi.ai/ja/docs/support/community-interaction) |
| 不具合と機能提案 | [GitHub Issues](https://github.com/QuantumNous/new-api/issues) |
| 脆弱性 | [セキュリティポリシー](./.github/SECURITY.md)に従って非公開で報告 |
</details> 不具合の報告には、バージョン、デプロイ方法、再現手順、機密情報を除いたログを添えてください。ドキュメント、翻訳、プロバイダー連携、対象を絞った回帰テストへの貢献も歓迎します。
### ⚠️ マルチマシンデプロイの注意事項
> [!WARNING]
> - すべてのノードで同じプライマリデータベースと同じ `SESSION_SECRET` を使用してください。異なる場合、Access Token、Refresh セッション、一時認証フローを一貫して検証できません。
> - 同じ Redis に接続するノードでは同じ `CRYPTO_SECRET` も設定してください。異なる場合、キャッシュキーのダイジェストが一致せず、共有エントリを正しく再利用できません。
ログイン Session とユーザー単位の有効数/発行数制限では、データベースが信頼できる唯一の情報源です。Redis の Session エントリは短期キャッシュであり、TTL は `SYNC_FREQUENCY`(デフォルト 60 秒)に従い、Session の残り有効期間を超えません。
| Redis トポロジー | Session 状態の伝播 | レート制限 |
| --- | --- | --- |
| すべてのノードで Redis を共有 | 取り消しとバージョン更新は通常即時に伝播 | Redis の制限枠はノード間で共有 |
| ノードごとに独立した Redis | 有効な `SYNC_FREQUENCY` 以内にデータベースへフォールバックして収束。バージョンローテーション直後の新しい Token は、古いキャッシュを持つノードで一時的に 401 になる場合があります | ノードごとに独立して計数するため、クラスター全体では設定値の約ノード数倍まで許可される可能性があります |
| Redis なし | Session の検証ごとにデータベースを直接参照 | メモリ内の制限枠はノードごとに独立 |
`SYNC_FREQUENCY` を短くすると独立 Redis のキャッシュ陳腐化時間は短くなりますが、有効な SID ごと、ノードごと、TTL ごとにデータベースへの主キー照会が 1 回増えます。この保証は Session 認証の陳腐化時間を限定するものです。レート制限や Redis を使うその他のコントロールプレーンキャッシュは、引き続きトポロジーに依存します。
Token、Origin 検証、PAT の契約については[ユーザー認証とログインセッション](./docs/authentication.md)を参照してください。
### 🔄 チャネルリトライとキャッシュ
**リトライ設定:** `設定 → 運営設定 → 一般設定 → 失敗リトライ回数`
**キャッシュ設定:**
- `REDIS_CONN_STRING`:Redisキャッシュ(推奨)
- `MEMORY_CACHE_ENABLED`:メモリキャッシュ
--- ---
@@ -447,36 +303,18 @@ Token、Origin 検証、PAT の契約については[ユーザー認証とログ
--- ---
## 💬 ヘルプサポート
### 📖 ドキュメントリソース
| リソース | リンク |
|------|------|
| 📘 よくある質問 | [FAQ](https://docs.newapi.pro/ja/docs/support/faq) |
| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/ja/docs/support/community-interaction) |
| 🐛 問題のフィードバック | [問題フィードバック](https://docs.newapi.pro/ja/docs/support/feedback-issues) |
| 📚 完全なドキュメント | [公式ドキュメント](https://docs.newapi.pro/ja/docs) |
### 🤝 貢献ガイド
あらゆる形の貢献を歓迎します!
- 🐛 バグを報告する
- 💡 新しい機能を提案する
- 📝 ドキュメントを改善する
- 🔧 コードを提出する
---
## 📜 ライセンス ## 📜 ライセンス
このプロジェクトは [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE) の下でライセンスされています。 このプロジェクトは [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE) の下でライセンスされています。
AGPLv3 第 7 条に基づく[追加条項](./NOTICE)が適用されます。変更版では、適切な法的表示と、画面上の目立つ概要・法的情報・フッター・著作者表示の箇所に `Frontend design and development by New API contributors.` を残し、元のプロジェクト <https://github.com/QuantumNous/new-api> への可視リンクを保持する必要があります。
本プロジェクトは、[One API](https://github.com/songquanpeng/one-api)(MITライセンス)をベースに開発されたオープンソースプロジェクトです。 本プロジェクトは、[One API](https://github.com/songquanpeng/one-api)(MITライセンス)をベースに開発されたオープンソースプロジェクトです。
お客様の組織のポリシーがAGPLv3ライセンスのソフトウェアの使用を許可していない場合、またはAGPLv3のオープンソース義務を回避したい場合は、こちらまでお問い合わせください:[support@quantumnous.com](mailto:support@quantumnous.com) お客様の組織のポリシーがAGPLv3ライセンスのソフトウェアの使用を許可していない場合、またはAGPLv3のオープンソース義務を回避したい場合は、こちらまでお問い合わせください:[support@quantumnous.com](mailto:support@quantumnous.com)
著作者と依存関係の表示については [NOTICE](./NOTICE) と[サードパーティライセンス](./THIRD-PARTY-LICENSES.md)を参照してください。
--- ---
## 🌟 スター履歴 ## 🌟 スター履歴
@@ -495,7 +333,7 @@ Token、Origin 検証、PAT の契約については[ユーザー認証とログ
このプロジェクトがあなたのお役に立てたなら、ぜひ ⭐️ スターをください! このプロジェクトがあなたのお役に立てたなら、ぜひ ⭐️ スターをください!
**[公式ドキュメント](https://docs.newapi.pro/ja/docs)** • **[問題フィードバック](https://github.com/Calcium-Ion/new-api/issues)** • **[最新リリース](https://github.com/Calcium-Ion/new-api/releases)** **[公式ドキュメント](https://docs.newapi.ai/ja/docs)** • **[問題フィードバック](https://github.com/Calcium-Ion/new-api/issues)** • **[最新リリース](https://github.com/Calcium-Ion/new-api/releases)**
<sub>❤️ で構築された QuantumNous</sub> <sub>❤️ で構築された QuantumNous</sub>
+169 -320
View File
@@ -4,7 +4,7 @@
# New API # New API
🍥 **Next-Generation LLM Gateway and AI Asset Management System** **An AI gateway for models, applications, and agents**
<p align="center"> <p align="center">
<a href="./README.zh_CN.md">简体中文</a> | <a href="./README.zh_CN.md">简体中文</a> |
@@ -44,23 +44,49 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#-quick-start">Quick Start</a> • <a href="#screenshots">Screenshots</a> •
<a href="#-key-features">Key Features</a> • <a href="#capabilities">Capabilities</a> •
<a href="#-deployment">Deployment</a> • <a href="#quick-start">Quick start</a> •
<a href="#-documentation">Documentation</a> • <a href="#deployment">Deployment</a> •
<a href="#-help-support">Help</a> <a href="#development">Development</a> •
<a href="#documentation">Documentation</a>
</p> </p>
</div> </div>
---
## 📝 Project Description ## 📝 Project Description
New API is a self-hosted AI gateway for applications, agents, and teams. Connect upstream model services, expose a consistent API to your clients, and manage routing, access, usage, and costs in one place.
Use it to share authorized model access across a team, switch providers without configuring every client again, or operate a private multi-model service with a web console. Upstreams include OpenAI, Anthropic, Google Gemini, Azure OpenAI, AWS Bedrock, Vertex AI, DeepSeek, Qwen, and other compatible services.
> [!IMPORTANT] > [!IMPORTANT]
> - This project is intended solely for lawful and authorized AI API gateway, organization-level authentication, multi-model management, usage analytics, cost accounting, and private deployment scenarios. > - This project is intended solely for lawful and authorized AI API gateway, organization-level authentication, multi-model management, usage analytics, cost accounting, and private deployment scenarios.
> - Users must lawfully obtain upstream API keys, accounts, model services, and interface permissions, and must comply with upstream terms of service and applicable laws and regulations. > - Users must lawfully obtain upstream API keys, accounts, model services, and interface permissions, and must comply with upstream terms of service and applicable laws and regulations.
> - Users should ensure their use complies with upstream terms of service and applicable laws and regulations. > - Users should ensure their use complies with upstream terms of service and applicable laws and regulations.
> - When providing generative AI services to the public, users should comply with applicable regulatory requirements and fulfill all filing, licensing, content safety, real-name verification, log retention, tax, and upstream authorization obligations required by their jurisdiction. > - When providing generative AI services to the public, users should comply with applicable regulatory requirements and fulfill all filing, licensing, content safety, real-name verification, log retention, tax, and upstream authorization obligations required by their jurisdiction.
<!-- -->
> [!WARNING]
> When operating this project as a public generative AI service or API resale service, users should first complete all required filing, licensing, content safety, real-name verification, log retention, tax, payment, and upstream authorization obligations.
---
<a id="screenshots"></a>
## Screenshots
Accounts, channels, plugin installation states, usage, prices, and costs shown below are simulated data. The marketplace displays the official plugin catalog. Click an image to view it at full size.
| Usage dashboard | Plugin marketplace |
| --- | --- |
| [![Usage dashboard with simulated requests, costs, and performance metrics](assets/screenshots/dashboard.en.jpg)](assets/screenshots/dashboard.en.jpg) | [![Official plugin marketplace with simulated installation status](assets/screenshots/plugin-marketplace.en.jpg)](assets/screenshots/plugin-marketplace.en.jpg) |
| **Model square** | **Usage logs** |
| [![Model square with simulated model pricing and availability](assets/screenshots/models.en.jpg)](assets/screenshots/models.en.jpg) | [![Usage logs with simulated tokens, costs, and response times](assets/screenshots/usage-logs.en.jpg)](assets/screenshots/usage-logs.en.jpg) |
--- ---
## 🤝 Trusted Partners ## 🤝 Trusted Partners
@@ -79,9 +105,6 @@
--><a href="https://bda.pku.edu.cn/" target="_blank"> --><a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="Peking University" height="80" /> <img src="./docs/images/pku.png" alt="Peking University" height="80" />
</a><!-- </a><!--
--><a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
</a><!--
--><a href="https://www.aliyun.com/" target="_blank"> --><a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" /> <img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
</a><!-- </a><!--
@@ -106,328 +129,174 @@
--- ---
## 🚀 Quick Start <a id="capabilities"></a>
### Using Docker Compose (Recommended) ## Capabilities
| Area | What you can do |
| --- | --- |
| Model access | Use OpenAI Chat Completions, Responses, Anthropic Messages, and Gemini APIs; stream responses and use tools, reasoning, and multimodal inputs where supported |
| Routing | Configure model mappings, channel priorities and weights, retries, channel affinity, and multiple upstream keys |
| Usage and costs | Manage quotas, subscriptions, usage logs, cache accounting, and expression-based pricing for different usage tiers |
| Access control | Manage users, groups, fine-grained permissions, and API key restrictions; use OAuth/OIDC, passkeys, two-factor authentication, and login session management |
| Asynchronous tasks | Extend image, video, and other task APIs with JavaScript plugins, including task status and output retrieval |
| Web console | Configure channels and models, inspect usage and audit logs, and try models in the playground; available in English, Simplified Chinese, Traditional Chinese, French, Japanese, Russian, and Vietnamese |
### Protocols and endpoints
| Interface | Common endpoints |
| --- | --- |
| OpenAI Chat / Responses | `POST /v1/chat/completions`, `POST /v1/responses` |
| Anthropic Messages | `POST /v1/messages` |
| Gemini | `POST /v1beta/models/{model}:generateContent`, `POST /v1beta/models/{model}:streamGenerateContent` |
| Realtime / Responses WebSocket | `GET /v1/realtime`, `GET /v1/responses` (WebSocket upgrade) |
| Images / audio | `/v1/images/generations`, `/v1/images/edits`, `/v1/audio/speech`, `/v1/audio/transcriptions`, `/v1/audio/translations` |
| Embeddings / rerank | `POST /v1/embeddings`, `POST /v1/rerank` |
| Task plugins | `POST /v1/tasks/{pluginKey}`, `GET /v1/tasks/{taskId}`, plus routes declared by each plugin |
[RelayKit](./relaykit/README.md) provides request, response, and streaming conversion between the four text protocols. Available features depend on the channel, upstream model, and conversion path; protocol-specific tools and fields may not map exactly. WebSocket support also requires a compatible upstream and channel configuration.
This README describes the current source tree. Check the release notes for the version you deploy.
<a id="quick-start"></a>
## Quick start
### Try locally with Docker
This starts a single instance with SQLite and binds it to localhost:
```bash
mkdir -p data
docker run --name new-api -d --restart unless-stopped \
-p 127.0.0.1:3000:3000 \
-e TZ=Asia/Shanghai \
-v "$(pwd)/data:/data" \
calciumion/new-api:latest
```
Open [http://localhost:3000](http://localhost:3000) and complete the setup wizard to create the administrator account. The `data` directory persists the SQLite database across container replacements.
### Make your first request
1. Add a channel with your upstream API key, available models, and group assignment; run a channel test.
2. Configure model pricing and ensure the user has quota or a valid subscription.
3. Create an API key in the console with access to the same group and models.
4. Set your client's base URL to `http://localhost:3000/v1` for OpenAI-compatible clients and use the **New API-issued key**.
Set `NEW_API_KEY` in your shell to that key. List the models accessible to it:
```bash
curl --fail-with-body http://localhost:3000/v1/models \
-H "Authorization: Bearer ${NEW_API_KEY}"
```
Then call Responses, replacing `your-enabled-model` with an enabled model that supports this interface:
```bash
curl --fail-with-body http://localhost:3000/v1/responses \
-H "Authorization: Bearer ${NEW_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"model":"your-enabled-model","input":"Hello!"}'
```
<a id="deployment"></a>
## Deployment
### Docker Compose
The repository's [Compose configuration](./docker-compose.yml) starts **New API + PostgreSQL + Redis** by default. It also contains examples for MySQL and a separate ClickHouse log database.
```bash ```bash
# Clone the project
git clone https://github.com/QuantumNous/new-api.git git clone https://github.com/QuantumNous/new-api.git
cd new-api cd new-api
# Edit docker-compose.yml configuration
nano docker-compose.yml
# Start the service
docker-compose up -d
``` ```
<details> Before starting, edit `docker-compose.yml`: replace the database and Redis example passwords in both the services and connection strings, and set a persistent random `SESSION_SECRET` (generate one with `openssl rand -hex 32`). For an HTTPS console, configure `SESSION_COOKIE_SECURE=true` and `SESSION_COOKIE_TRUSTED_URL` with its exact public HTTPS origin.
<summary><strong>Using Docker Commands</strong></summary>
```bash ```bash
# Pull the latest image docker compose up -d
docker pull calciumion/new-api:latest docker compose logs -f new-api
# Using SQLite (default)
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# Using MySQL
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
``` ```
> **💡 Tip:** `-v ./data:/data` will save data in the `data` folder of the current directory, you can also change it to an absolute path like `-v /your/custom/path:/data` ### Storage and configuration
</details> | Component | Options |
| --- | --- |
| Main database | SQLite, MySQL ≥ 5.7.8, or PostgreSQL ≥ 9.6 |
| Separate log database | Configure with `LOG_SQL_DSN`; also supports ClickHouse |
| Cache | Optional Redis plus in-memory caching; use shared Redis when application nodes need shared rate limits |
| Container platforms | Linux amd64 / arm64 |
--- | Variable | Purpose |
| --- | --- |
| `SQL_DSN` | Main database connection; unset uses SQLite |
| `LOG_SQL_DSN` | Optional separate log database connection |
| `REDIS_CONN_STRING` | Redis connection string |
| `SESSION_SECRET` | Persistent authentication secret; all nodes must use the same value |
| `CRYPTO_SECRET` | Defaults to `SESSION_SECRET`; nodes sharing Redis must use the same effective value |
| `SESSION_COOKIE_SECURE` | Set to `true` for an HTTPS console; enables Secure refresh cookies and strict refresh/logout origin checks |
| `SESSION_COOKIE_TRUSTED_URL` | Required in Secure mode: comma-separated exact HTTPS origins, without paths or wildcards; leave unset for local HTTP |
| `TRUSTED_PROXIES` | Trusted reverse-proxy IPs/CIDRs, or `none`; explicitly configure for your network |
🎉 After deployment is complete, visit `http://localhost:3000` to start using! See the [environment example](./.env.example), [environment reference](https://docs.newapi.ai/en/docs/installation/config-maintenance/environment-variables), and [authentication and session guide](./docs/authentication.md) for full configuration. Configure container variables in Compose's `environment` or `env_file`; copying `.env.example` alone does not inject variables into the container.
> [!WARNING] For production, put the console behind HTTPS and configure your reverse proxy for streaming and WebSocket upgrades. Persist and back up the database and mounted data. Multi-node deployments must share the main database and authentication secrets; separate Redis instances or in-memory rate limiters count limits independently per node. The session guide describes propagation behavior for each topology.
> When operating this project as a public generative AI service or API resale service, users should first complete all required filing, licensing, content safety, real-name verification, log retention, tax, payment, and upstream authorization obligations.
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation) Pin an image version from [Releases](https://github.com/QuantumNous/new-api/releases), review its upgrade notes, and back up before upgrading. The `latest` tag follows published builds and can change; migrations and compatibility must be assessed for your existing installation.
--- <a id="development"></a>
## 📚 Documentation ## Development and extensions
<div align="center"> The backend uses Go and Gin. The web console uses React 19, TypeScript, Rsbuild, TanStack, and Tailwind CSS 4. Use Bun for frontend dependencies and scripts; see [go.mod](./go.mod) for the Go language baseline and [Dockerfile](./Dockerfile) for the container build toolchain.
### 📖 [Official Documentation](https://docs.newapi.pro/en/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) Build the frontend before starting the backend, which embeds `web/dist`:
</div>
**Quick Navigation:**
| Category | Link |
|------|------|
| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/en/docs/installation) |
| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) |
| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/en/docs/api) |
| ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
---
## ✨ Key Features
> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction)
### 🎨 Core Functions
| Feature | Description |
|------|------|
| 🎨 New UI | Modern user interface design |
| 🌍 Multi-language | Supports Simplified Chinese, Traditional Chinese, English, French, Japanese |
| 🔄 Data Compatibility | Fully compatible with the original One API database |
| 📈 Data Dashboard | Visual console and statistical analysis |
| 🔒 Permission Management | Token grouping, model restrictions, user management |
### 💰 Authorized Usage Accounting and Billing
- ✅ Internal top-up and quota allocation for lawful authorized scenarios (EPay, Stripe)
- ✅ Organization-level per-request, usage-based, and cache-hit cost accounting
- ✅ Cache billing statistics for OpenAI, Azure, DeepSeek, Claude, Qwen, and supported models
- ✅ Flexible billing policies for internal management or authorized enterprise customers
### 🔐 Authorization and Security
- 😈 Discord authorization login
- 🤖 LinuxDO authorization login
- 📱 Telegram authorization login
- 🔑 OIDC unified authentication
- 🔍 Key quota query usage (with [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
### 🚀 Advanced Features
**API Format Support:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (including Azure)
- ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
- ⚡ [Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
- 🔄 [Rerank Models](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina)
**Intelligent Routing:**
- ⚖️ Channel weighted random
- 🔄 Automatic retry on failure
- 🚦 User-level model rate limiting
**Format Conversion:**
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
- 🔄 **OpenAI Compatible → Google Gemini**
- 🔄 **Google Gemini → OpenAI Compatible** - Text only, function calling not supported yet
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - In development
- 🔄 **Thinking-to-content functionality**
**Reasoning Effort Support:**
<details>
<summary>View detailed configuration</summary>
**OpenAI series models:**
- `o3-mini-high` - High reasoning effort
- `o3-mini-medium` - Medium reasoning effort
- `o3-mini-low` - Low reasoning effort
- `gpt-5-high` - High reasoning effort
- `gpt-5-medium` - Medium reasoning effort
- `gpt-5-low` - Low reasoning effort
**Claude thinking models:**
- `claude-3-7-sonnet-20250219-thinking` - Enable thinking mode
**Google Gemini series models:**
- `gemini-2.5-flash-thinking` - Enable thinking mode
- `gemini-2.5-flash-nothinking` - Disable thinking mode
- `gemini-2.5-pro-thinking` - Enable thinking mode
- `gemini-2.5-pro-thinking-128` - Enable thinking mode with thinking budget of 128 tokens
- You can also append `-low`, `-medium`, or `-high` to any Gemini model name to request the corresponding reasoning effort (no extra thinking-budget suffix needed).
</details>
---
## 🤖 Model Support
> For details, please refer to [API Documentation - Gateway Interface](https://docs.newapi.pro/en/docs/api)
| Model Type | Description | Documentation |
|---------|------|------|
| 🤖 OpenAI-Compatible | OpenAI compatible models | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createchatcompletion) |
| 🤖 OpenAI Responses | OpenAI Responses format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createresponse) |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/creatererank) |
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/createmessage) |
| 🌐 Gemini | Google Gemini format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
| 🔧 Dify | ChatFlow mode | - |
| 🎯 Custom upstream | Supports configuring legally authorized upstream endpoints | - |
### 📡 Supported Interfaces
<details>
<summary>View complete interface list</summary>
- [Chat Interface (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createchatcompletion)
- [Response Interface (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/createresponse)
- [Image Interface (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/post-v1-images-generations)
- [Audio Interface (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
- [Video Interface (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/sora/createvideo)
- [Embedding Interface (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/createembedding)
- [Rerank Interface (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/creatererank)
- [Realtime Conversation (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/createrealtimesession)
- [Claude Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/createmessage)
- [Google Gemini Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/gemini/geminirelayv1beta)
</details>
---
## 🚢 Deployment
> [!TIP]
> **Latest Docker image:** `calciumion/new-api:latest`
### 📋 Deployment Requirements
| Component | Requirement |
|------|------|
| **Local database** | SQLite (Docker must mount `/data` directory)|
| **Remote database** | MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 |
| **Container engine** | Docker / Docker Compose |
| **System architecture** | 64-bit only (amd64 / arm64); 32-bit systems are not supported |
### ⚙️ Environment Variable Configuration
<details>
<summary>Common environment variable configuration</summary>
| Variable Name | Description | Default Value |
|--------|------|--------|
| `SESSION_SECRET` | Authentication signing secret; must be identical on every node | - |
| `SESSION_COOKIE_SECURE` | `false`/unset disables the refresh/logout OriginGuard for local HTTP dev proxies; `true` enables the Secure cookie and strict Origin checks | `false` |
| `SESSION_COOKIE_TRUSTED_URL` | Required with Secure mode: comma-separated exact HTTPS Origins allowed to call refresh/logout; not a relay CORS allowlist | - |
| `TRUSTED_PROXIES` | Unset/blank trusts loopback, RFC 1918 and IPv6 ULA with a startup warning; `none` trusts no proxies; an explicit proxy IP/CIDR list replaces the defaults | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
| `USER_SESSION_ACTIVE_LIMIT` | Maximum active login Sessions per user | `50` |
| `USER_SESSION_ISSUANCE_LIMIT` | Maximum Sessions created per user within the issuance window, including revoked Sessions | `100` |
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Per-user Session issuance window; clamped to the revoked retention period when configured higher | `86400` |
| `USER_SESSION_REVOKED_RETENTION_DAYS` | Days to retain revoked Session rows for audit and issuance accounting | `7` |
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | Global Sessions created per hour that triggers an alert only; it never blocks login | `5000` |
| `CRYPTO_SECRET` | HMAC secret for cache keys; nodes sharing Redis must use the same effective value | Defaults to `SESSION_SECRET` |
| `SQL_DSN` | Database connection string | - |
| `REDIS_CONN_STRING` | Redis connection string | - |
| `RELAY_IDLE_CONN_TIMEOUT` | Idle keep-alive timeout for relay HTTP clients, seconds. Defaults to Go standard library behavior; set `0` to disable | `90` |
| `RELAY_RESPONSE_HEADER_TIMEOUT` | How long the relay waits for upstream **response headers**, seconds; set `0` to disable. Only bounds the header wait -- streaming after the headers arrive is unaffected. Note that non-streaming upstreams usually send headers only once generation finishes, so leave headroom | `1800` |
| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
| `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` |
| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` |
| `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | Error log switch | `false` |
| `PYROSCOPE_URL` | Pyroscope server address | - |
| `PYROSCOPE_APP_NAME` | Pyroscope application name | `new-api` |
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope basic auth user | - |
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope basic auth password | - |
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex sampling rate | `5` |
| `PYROSCOPE_BLOCK_RATE` | Pyroscope block sampling rate | `5` |
| `HOSTNAME` | Hostname tag for Pyroscope | `new-api` |
📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables)
</details>
### 🔧 Deployment Methods
<details>
<summary><strong>Method 1: Docker Compose (Recommended)</strong></summary>
```bash ```bash
# Clone the project # Repository root
git clone https://github.com/QuantumNous/new-api.git cd web
cd new-api bun install --frozen-lockfile
bun run build
# Edit configuration cd ..
nano docker-compose.yml go run .
# Start service
docker-compose up -d
``` ```
</details> In a second terminal, start the frontend development server:
<details>
<summary><strong>Method 2: Docker Commands</strong></summary>
**Using SQLite:**
```bash ```bash
docker run --name new-api -d --restart always \ cd web
-p 3000:3000 \ bun run dev -- --port 5173
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
``` ```
**Using MySQL:** Open [http://localhost:5173](http://localhost:5173); the development server proxies API requests to the backend on port 3000. For a containerized development backend, see [docker-compose.dev.yml](./docker-compose.dev.yml) and the `make dev` target in [makefile](./makefile).
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 Path explanation:** | Location | Responsibility |
> - `./data:/data` - Relative path, data saved in the data folder of the current directory | --- | --- |
> - You can also use absolute path, e.g.: `/your/custom/path:/data` | `router/`, `middleware/`, `controller/` | HTTP routes, access checks, and API handlers |
| `relay/` | Upstream adapters and request routing |
| `service/`, `model/` | Business logic and persistence |
| [relaykit/](./relaykit/README.md) | Independently buildable Go module for protocol DTOs and conversions |
| [plugins/tasks/](./plugins/tasks/) | JavaScript task plugins; see [Task Plugin API v1](./docs/plugin-api/v1.md) for authoring and host boundaries |
| `web/` | Web console; see [frontend conventions](./web/AGENTS.md) |
| [electron/](./electron/README.md) | Desktop wrapper and packaging |
</details> 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/`.
<details> <a id="documentation"></a>
<summary><strong>Method 3: BaoTa Panel</strong></summary>
1. Install BaoTa Panel (≥ 9.2.0 version) ## Documentation and community
2. Search for **New-API** in the application store
3. One-click installation
📖 [Tutorial with images](./docs/BT.md) | Resource | Link |
| --- | --- |
| Official documentation | [Guides](https://docs.newapi.ai/en/docs) · [Installation](https://docs.newapi.ai/en/docs/installation) · [API reference](https://docs.newapi.ai/en/docs/api) |
| Project exploration | [DeepWiki](https://deepwiki.com/QuantumNous/new-api) |
| Questions and discussion | [FAQ](https://docs.newapi.ai/en/docs/support/faq) · [Community](https://docs.newapi.ai/en/docs/support/community-interaction) |
| Bugs and feature requests | [GitHub Issues](https://github.com/QuantumNous/new-api/issues) |
| Security reports | Follow the [security policy](./.github/SECURITY.md) for private reporting |
</details> For bug reports, include the version, deployment method, reproduction steps, and redacted logs. Documentation, translations, provider integrations, and focused regression tests are all welcome contributions.
### ⚠️ Multi-machine Deployment Considerations
> [!WARNING]
> - All nodes must use the same primary database and the same `SESSION_SECRET`; otherwise Access Tokens, refresh sessions, and temporary authentication flows cannot be verified consistently.
> - Nodes connected to the same Redis must also use the same `CRYPTO_SECRET`, or their cache-key digests will differ and shared entries cannot be reused consistently.
The database is authoritative for login Sessions and for the per-user active/issuance limits. Redis Session entries are short-lived caches whose TTL follows `SYNC_FREQUENCY` (60 seconds by default) and never exceeds the Session's remaining lifetime.
| Redis topology | Session propagation | Rate limiting |
| --- | --- | --- |
| Shared Redis | Revocations and version publications normally propagate immediately | Redis limits are shared across nodes |
| Independent Redis per node | Nodes converge from the database within the effective `SYNC_FREQUENCY`; a newly rotated token may receive a temporary 401 on a node with stale cache | Each node has its own allowance, so aggregate capacity can reach roughly the configured limit multiplied by the node count |
| No Redis | Every Session validation reads the database | In-memory limits are independent per node |
A shorter `SYNC_FREQUENCY` reduces the independent-Redis staleness window but causes one additional primary-key Session lookup per active SID, per node, per TTL. These guarantees make Session authentication bounded-stale across the supported topologies; rate limits and other Redis-backed control-plane caches remain topology-dependent.
See [User authentication and login sessions](./docs/authentication.md) for the token, Origin-check and PAT contracts.
### 🔄 Channel Retry and Cache
**Retry configuration:** `Settings → Operation Settings → General Settings → Failure Retry Count`
**Cache configuration:**
- `REDIS_CONN_STRING`: Redis cache (recommended)
- `MEMORY_CACHE_ENABLED`: Memory cache
--- ---
@@ -449,28 +318,6 @@ See [User authentication and login sessions](./docs/authentication.md) for the t
--- ---
## 💬 Help Support
### 📖 Documentation Resources
| Resource | Link |
|------|------|
| 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/en/docs/support/feedback-issues) |
| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/en/docs) |
### 🤝 Contribution Guide
Welcome all forms of contribution!
- 🐛 Report Bugs
- 💡 Propose New Features
- 📝 Improve Documentation
- 🔧 Submit Code
---
## 📜 License ## 📜 License
This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE). This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
@@ -487,6 +334,8 @@ This is an open-source project developed based on [One API](https://github.com/s
If your organization's policies do not permit the use of AGPLv3-licensed software, or if you wish to avoid the open-source obligations of AGPLv3, please contact us at: [support@quantumnous.com](mailto:support@quantumnous.com) If your organization's policies do not permit the use of AGPLv3-licensed software, or if you wish to avoid the open-source obligations of AGPLv3, please contact us at: [support@quantumnous.com](mailto:support@quantumnous.com)
See [NOTICE](./NOTICE) and [third-party licenses](./THIRD-PARTY-LICENSES.md) for attribution and dependency notices.
--- ---
## 🌟 Star History ## 🌟 Star History
@@ -505,7 +354,7 @@ If your organization's policies do not permit the use of AGPLv3-licensed softwar
If this project is helpful to you, welcome to give us a ⭐️ Star! If this project is helpful to you, welcome to give us a ⭐️ Star!
**[Official Documentation](https://docs.newapi.pro/en/docs)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)** **[Official Documentation](https://docs.newapi.ai/en/docs)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Built with ❤️ by QuantumNous</sub> <sub>Built with ❤️ by QuantumNous</sub>
+172 -319
View File
@@ -4,10 +4,10 @@
# New API # New API
🍥 **新一代大模型网关与AI资产管理系统** **连接模型、应用与 Agent 的 AI 网关**
<p align="center"> <p align="center">
简体中文 | <strong>简体中文</strong> |
<a href="./README.zh_TW.md">繁體中文</a> | <a href="./README.zh_TW.md">繁體中文</a> |
<a href="./README.md">English</a> | <a href="./README.md">English</a> |
<a href="./README.fr.md">Français</a> | <a href="./README.fr.md">Français</a> |
@@ -44,23 +44,49 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#-快速开始">快速开始</a> • <a href="#screenshots">项目截图</a> •
<a href="#-主要特性">主要特性</a> • <a href="#capabilities">核心能力</a> •
<a href="#-部署">部署</a> • <a href="#quick-start">快速开始</a> •
<a href="#-文档">文档</a> • <a href="#deployment">部署运维</a> •
<a href="#-帮助支持">帮助</a> <a href="#development">开发扩展</a> •
<a href="#documentation">文档社区</a>
</p> </p>
</div> </div>
---
## 📝 项目说明 ## 📝 项目说明
New API 是面向应用、Agent 和团队的自托管 AI 网关。将不同厂商的模型服务接入统一入口,在同一套控制台中管理渠道、访问权限、用量与成本。
你可以用它为团队分配已授权的模型资源,在切换上游时减少客户端改动,或搭建自己的多模型服务。支持接入 OpenAI、Anthropic、Google Gemini、Azure OpenAI、AWS Bedrock、Vertex AI、DeepSeek、通义千问及其他兼容服务。
> [!IMPORTANT] > [!IMPORTANT]
> - 本项目仅面向合法授权的 AI API 网关、组织内部鉴权、多模型管理、用量统计、成本核算和私有化部署场景。 > - 本项目仅面向合法授权的 AI API 网关、组织内部鉴权、多模型管理、用量统计、成本核算和私有化部署场景。
> - 使用者必须合法取得上游 API Key、账号、模型服务或接口权限,并遵守上游服务条款及适用法律法规。 > - 使用者必须合法取得上游 API Key、账号、模型服务或接口权限,并遵守上游服务条款及适用法律法规。
> - 使用者应确保其使用方式符合上游服务条款及适用法律法规。 > - 使用者应确保其使用方式符合上游服务条款及适用法律法规。
> - 面向公众提供生成式人工智能服务时,使用者应遵守[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)等监管要求,自行完成所在司法辖区要求的备案、许可、内容安全、实名、日志留存、税务和上游授权等合规义务。 > - 面向公众提供生成式人工智能服务时,使用者应遵守[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)等监管要求,自行完成所在司法辖区要求的备案、许可、内容安全、实名、日志留存、税务和上游授权等合规义务。
<!-- -->
> [!WARNING]
> 将本项目作为面向公众的生成式 AI 服务或 API 转售服务运营时,使用者应先完成备案、内容安全、实名、日志留存、税务、支付和上游授权等合规义务。
---
<a id="screenshots"></a>
## 项目截图
以下账号、渠道、插件安装状态、用量、价格和费用均为模拟数据,插件市场展示官方目录。点击图片可查看原图。
| 数据看板 | 插件市场 |
| --- | --- |
| [![数据看板:模拟请求量、费用和性能指标](assets/screenshots/dashboard.zh-CN.jpg)](assets/screenshots/dashboard.zh-CN.jpg) | [![插件市场:官方插件目录与模拟安装状态](assets/screenshots/plugin-marketplace.zh-CN.jpg)](assets/screenshots/plugin-marketplace.zh-CN.jpg) |
| **模型广场** | **使用日志** |
| [![模型广场:模拟模型价格与可用状态](assets/screenshots/models.zh-CN.jpg)](assets/screenshots/models.zh-CN.jpg) | [![使用日志:模拟 Token 用量、费用和响应耗时](assets/screenshots/usage-logs.zh-CN.jpg)](assets/screenshots/usage-logs.zh-CN.jpg) |
--- ---
## 🤝 我们信任的合作伙伴 ## 🤝 我们信任的合作伙伴
@@ -79,9 +105,6 @@
--><a href="https://bda.pku.edu.cn/" target="_blank"> --><a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="北京大学" height="80" /> <img src="./docs/images/pku.png" alt="北京大学" height="80" />
</a><!-- </a><!--
--><a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud 优刻得" height="80" />
</a><!--
--><a href="https://www.aliyun.com/" target="_blank"> --><a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="阿里云" height="80" /> <img src="./docs/images/aliyun.png" alt="阿里云" height="80" />
</a><!-- </a><!--
@@ -106,326 +129,174 @@
--- ---
## 🚀 快速开始 <a id="capabilities"></a>
### 使用 Docker Compose(推荐) ## 核心能力
| 方向 | 可以做什么 |
| --- | --- |
| 模型接入 | 支持 OpenAI Chat Completions、Responses、Anthropic Messages 和 Gemini 协议,以及上游支持的流式输出、工具调用、推理与多模态输入 |
| 渠道调度 | 配置模型映射、渠道优先级与权重、失败重试、渠道亲和性和多密钥管理 |
| 用量与成本 | 管理额度、订阅套餐、用量日志、缓存计费,以及基于表达式的阶梯定价 |
| 访问控制 | 管理用户、分组、细粒度权限和 API Key 限制;支持 OAuth/OIDC、通行密钥、两步验证与登录会话管理 |
| 异步任务 | 通过 JavaScript 插件扩展图片、视频等任务 API,统一查询任务状态和获取产物 |
| Web 控制台 | 配置渠道与模型、查看用量和审计日志、在 Playground 中调试模型;支持简体中文、繁体中文、英语、法语、日语、俄语和越南语 |
### 协议与接口
| 接口类型 | 常用入口 |
| --- | --- |
| OpenAI Chat / Responses | `POST /v1/chat/completions`、`POST /v1/responses` |
| Anthropic Messages | `POST /v1/messages` |
| Gemini | `POST /v1beta/models/{model}:generateContent`、`POST /v1beta/models/{model}:streamGenerateContent` |
| Realtime / Responses WebSocket | `GET /v1/realtime`、`GET /v1/responses`(WebSocket 升级) |
| 图片 / 音频 | `/v1/images/generations`、`/v1/images/edits`、`/v1/audio/speech`、`/v1/audio/transcriptions`、`/v1/audio/translations` |
| 向量 / 重排 | `POST /v1/embeddings`、`POST /v1/rerank` |
| 任务插件 | `POST /v1/tasks/{pluginKey}`、`GET /v1/tasks/{taskId}`,以及各插件声明的协议路由 |
[RelayKit](./relaykit/README.md) 提供上述四种文本协议之间的请求、响应和流式转换。实际可用能力取决于渠道、上游模型和转换路径;协议特有的工具与字段可能无法完整映射。WebSocket 同样需要上游与渠道配置支持。
本 README 描述当前源码的能力,部署时请同时查看所选版本的发布说明。
<a id="quick-start"></a>
## 快速开始
### 使用 Docker 本地体验
以下命令使用 SQLite 启动单实例,仅监听本机端口:
```bash
mkdir -p data
docker run --name new-api -d --restart unless-stopped \
-p 127.0.0.1:3000:3000 \
-e TZ=Asia/Shanghai \
-v "$(pwd)/data:/data" \
calciumion/new-api:latest
```
打开 [http://localhost:3000](http://localhost:3000),按初始化向导创建管理员账号。SQLite 数据库存放在挂载的 `data` 目录中,更换容器后仍会保留。
### 发起第一次请求
1. 新建渠道,填写上游 API Key、可用模型和所属分组,执行渠道测试。
2. 配置模型价格,确保调用用户有可用额度或有效订阅。
3. 在控制台创建 API Key,确保它能访问对应分组和模型。
4. 对于 OpenAI 兼容客户端,将 Base URL 设为 `http://localhost:3000/v1`,密钥使用 **New API 签发的 Key**。
在终端中将 `NEW_API_KEY` 环境变量设为该密钥,查询它可以访问的模型:
```bash
curl --fail-with-body http://localhost:3000/v1/models \
-H "Authorization: Bearer ${NEW_API_KEY}"
```
然后调用 Responses,将 `your-enabled-model` 替换为已启用且支持该接口的模型名称:
```bash
curl --fail-with-body http://localhost:3000/v1/responses \
-H "Authorization: Bearer ${NEW_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"model":"your-enabled-model","input":"Hello!"}'
```
<a id="deployment"></a>
## 部署与运维
### Docker Compose
仓库的 [Compose 配置](./docker-compose.yml) 默认启动 **New API + PostgreSQL + Redis**,并提供 MySQL 和独立 ClickHouse 日志库的配置示例。
```bash ```bash
# 克隆项目
git clone https://github.com/QuantumNous/new-api.git git clone https://github.com/QuantumNous/new-api.git
cd new-api cd new-api
# 编辑 docker-compose.yml 配置
nano docker-compose.yml
# 启动服务
docker-compose up -d
``` ```
<details> 启动前编辑 `docker-compose.yml`:同时替换数据库、Redis 服务及对应连接串中的示例密码,并设置固定的随机 `SESSION_SECRET`(可用 `openssl rand -hex 32` 生成)。通过 HTTPS 访问控制台时,设置 `SESSION_COOKIE_SECURE=true`,并在 `SESSION_COOKIE_TRUSTED_URL` 中填写控制台对外的精确 HTTPS Origin。
<summary><strong>使用 Docker 命令</strong></summary>
```bash ```bash
# 拉取最新镜像 docker compose up -d
docker pull calciumion/new-api:latest docker compose logs -f new-api
# 使用 SQLite(默认)
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# 使用 MySQL
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
``` ```
> **💡 提示:** `-v ./data:/data` 会将数据保存在当前目录的 `data` 文件夹中,你也可以改为绝对路径如 `-v /your/custom/path:/data` ### 存储与配置
</details> | 组件 | 可选方案 |
| --- | --- |
| 主数据库 | SQLite、MySQL ≥ 5.7.8、PostgreSQL ≥ 9.6 |
| 独立日志库 | 通过 `LOG_SQL_DSN` 配置,额外支持 ClickHouse |
| 缓存 | 可选 Redis 与内存缓存;多节点需要共享限流额度时使用共享 Redis |
| 容器平台 | Linux amd64 / arm64 |
--- | 环境变量 | 用途 |
| --- | --- |
| `SQL_DSN` | 主数据库连接串;未设置时使用 SQLite |
| `LOG_SQL_DSN` | 可选的独立日志数据库连接串 |
| `REDIS_CONN_STRING` | Redis 连接串 |
| `SESSION_SECRET` | 持久保存的鉴权密钥,所有节点必须一致 |
| `CRYPTO_SECRET` | 默认使用 `SESSION_SECRET`;共享 Redis 的节点必须使用相同的有效值 |
| `SESSION_COOKIE_SECURE` | HTTPS 控制台设为 `true`,启用 Secure 刷新 Cookie 和严格的刷新/退出来源校验 |
| `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:以逗号分隔的精确 HTTPS Origin,不含路径或通配符;本地 HTTP 模式不设置 |
| `TRUSTED_PROXIES` | 可信反向代理 IP/CIDR,或 `none`;按实际网络显式配置 |
🎉 部署完成后,访问 `http://localhost:3000` 即可使用! 完整配置见[环境变量示例](./.env.example)、[环境变量文档](https://docs.newapi.ai/zh/docs/installation/config-maintenance/environment-variables)和[鉴权与登录会话说明](./docs/authentication.md)。容器变量应通过 Compose 的 `environment` 或 `env_file` 注入;只复制 `.env.example` 不会自动将变量传入容器。
> [!WARNING] 正式部署时使用 HTTPS,并配置反向代理支持流式响应与 WebSocket 升级。持久化并备份数据库和挂载数据。多节点必须共用主数据库和鉴权密钥;独立 Redis 或内存限流器会按节点分别计数。不同拓扑下的会话传播行为见鉴权文档。
> 将本项目作为面向公众的生成式 AI 服务或 API 转售服务运营时,使用者应先完成备案、内容安全、实名、日志留存、税务、支付和上游授权等合规义务。
📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/zh/docs/installation) 从[发布页](https://github.com/QuantumNous/new-api/releases)选择明确的镜像版本,阅读升级说明并先备份再升级。`latest` 会随发布构建变化;已有实例应按实际版本评估迁移与兼容性。
--- <a id="development"></a>
## 📚 文档 ## 开发与扩展
<div align="center"> 后端使用 Go 和 Gin;控制台使用 React 19、TypeScript、Rsbuild、TanStack 与 Tailwind CSS 4。前端依赖和脚本使用 Bun;Go 语言基线见 [go.mod](./go.mod),容器构建工具链见 [Dockerfile](./Dockerfile)。
### 📖 [官方文档](https://docs.newapi.pro/zh/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) 后端会嵌入 `web/dist`,首次启动前先构建前端:
</div>
**快速导航:**
| 分类 | 链接 |
|------|------|
| 🚀 部署指南 | [安装文档](https://docs.newapi.pro/zh/docs/installation) |
| ⚙️ 环境配置 | [环境变量](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables) |
| 📡 接口文档 | [API 文档](https://docs.newapi.pro/zh/docs/api) |
| ❓ 常见问题 | [FAQ](https://docs.newapi.pro/zh/docs/support/faq) |
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/zh/docs/support/community-interaction) |
---
## ✨ 主要特性
> 详细特性请参考 [特性说明](https://docs.newapi.pro/zh/docs/guide/wiki/basic-concepts/features-introduction)
### 🎨 核心功能
| 特性 | 说明 |
|------|------|
| 🎨 全新 UI | 现代化的用户界面设计 |
| 🌍 多语言 | 支持中文、英文、法语、日语 |
| 🔄 数据兼容 | 完全兼容原版 One API 数据库 |
| 📈 数据看板 | 可视化控制台与统计分析 |
| 🔒 权限管理 | 令牌分组、模型限制、用户管理 |
### 💰 授权用量与成本管理
- ✅ 合法授权场景下的内部充值与额度分配(易支付、Stripe)
- ✅ 组织内按次、按量或缓存命中成本核算
- ✅ 支持 OpenAI、Azure、DeepSeek、Claude、Qwen 等模型的缓存计费统计
- ✅ 面向内部管理或企业客户的灵活计费策略配置
### 🔐 授权与安全
- 😈 Discord 授权登录
- 🤖 LinuxDO 授权登录
- 📱 Telegram 授权登录
- 🔑 OIDC 统一认证
- 🔍 Key 查询使用额度(配合 [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
### 🚀 高级功能
**API 格式支持:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-response)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/create-realtime-session)(含 Azure)
- ⚡ [Claude Messages](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message)
- ⚡ [Google Gemini](https://doc.newapi.pro/api/google-gemini-chat)
- 🔄 [Rerank 模型](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank)(Cohere、Jina)
**智能路由:**
- ⚖️ 渠道加权随机
- 🔄 失败自动重试
- 🚦 用户级别模型限流
**格式转换:**
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
- 🔄 **OpenAI Compatible → Google Gemini**
- 🔄 **Google Gemini → OpenAI Compatible** - 仅支持文本,暂不支持函数调用
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - 开发中
- 🔄 **思考转内容功能**
**Reasoning Effort 支持:**
<details>
<summary>查看详细配置</summary>
**OpenAI 系列模型:**
- `o3-mini-high` - High reasoning effort
- `o3-mini-medium` - Medium reasoning effort
- `o3-mini-low` - Low reasoning effort
- `gpt-5-high` - High reasoning effort
- `gpt-5-medium` - Medium reasoning effort
- `gpt-5-low` - Low reasoning effort
**Claude 思考模型:**
- `claude-3-7-sonnet-20250219-thinking` - 启用思考模式
**Google Gemini 系列模型:**
- `gemini-2.5-flash-thinking` - 启用思考模式
- `gemini-2.5-flash-nothinking` - 禁用思考模式
- `gemini-2.5-pro-thinking` - 启用思考模式
- `gemini-2.5-pro-thinking-128` - 启用思考模式,并设置思考预算为128tokens
- 也可以直接在 Gemini 模型名称后追加 `-low` / `-medium` / `-high` 来控制思考力度(无需再设置思考预算后缀)
</details>
---
## 🤖 模型支持
> 详情请参考 [接口文档 - 网关接口](https://docs.newapi.pro/zh/docs/api)
| 模型类型 | 说明 | 文档 |
|---------|------|------|
| 🤖 OpenAI-Compatible | OpenAI 兼容模型 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createchatcompletion) |
| 🤖 OpenAI Responses | OpenAI Responses 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createresponse) |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文档](https://doc.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文档](https://doc.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere、Jina | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank) |
| 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/createmessage) |
| 🌐 Gemini | Google Gemini 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
| 🔧 Dify | ChatFlow 模式 | - |
| 🎯 自定义上游 | 支持配置合法授权的上游接口地址 | - |
### 📡 支持的接口
<details>
<summary>查看完整接口列表</summary>
- [聊天接口 (Chat Completions)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createchatcompletion)
- [响应接口 (Responses)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createresponse)
- [图像接口 (Image)](https://docs.newapi.pro/zh/docs/api/ai-model/images/openai/post-v1-images-generations)
- [音频接口 (Audio)](https://docs.newapi.pro/zh/docs/api/ai-model/audio/openai/create-transcription)
- [视频接口 (Video)](https://docs.newapi.pro/zh/docs/api/ai-model/videos/sora/createvideo)
- [嵌入接口 (Embeddings)](https://docs.newapi.pro/zh/docs/api/ai-model/embeddings/createembedding)
- [重排序接口 (Rerank)](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/creatererank)
- [实时对话 (Realtime)](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/createrealtimesession)
- [Claude 聊天](https://docs.newapi.pro/zh/docs/api/ai-model/chat/createmessage)
- [Google Gemini 聊天](https://docs.newapi.pro/zh/docs/api/ai-model/chat/gemini/geminirelayv1beta)
</details>
---
## 🚢 部署
> [!TIP]
> **最新版 Docker 镜像:** `calciumion/new-api:latest`
### 📋 部署要求
| 组件 | 要求 |
|------|------|
| **本地数据库** | SQLite(Docker 需挂载 `/data` 目录)|
| **远程数据库** | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6 |
| **容器引擎** | Docker / Docker Compose |
| **系统架构** | 仅支持 64 位系统(amd64 / arm64),不支持 32 位系统 |
### ⚙️ 环境变量配置
<details>
<summary>常用环境变量配置</summary>
| 变量名 | 说明 | 默认值 |
|--------|--------------------------------------------------------------|--------|
| `SESSION_SECRET` | 鉴权签名密钥;所有节点必须保持一致 | - |
| `SESSION_COOKIE_SECURE` | `false`/未配置时关闭 refresh/logout OriginGuard 以兼容本地 HTTP 开发代理;`true` 时启用 Secure Cookie 和严格 Origin 校验 | `false` |
| `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:允许调用 refresh/logout 的精确 HTTPS Origin,多个用英文逗号分隔;不是 relay CORS 白名单 | - |
| `TRUSTED_PROXIES` | 未配置/留空时信任回环、RFC1918 和 IPv6 ULA 并输出启动告警;`none` 不信任任何代理;显式代理 IP/CIDR 列表完全替代默认值 | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
| `USER_SESSION_ACTIVE_LIMIT` | 单用户最大活跃登录 Session 数 | `50` |
| `USER_SESSION_ISSUANCE_LIMIT` | 单用户在签发窗口内可创建的 Session 总数,包含已撤销 Session | `100` |
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 签发计数窗口(秒);高于 revoked 保留期时自动钳制 | `86400` |
| `USER_SESSION_REVOKED_RETENTION_DAYS` | revoked Session 用于审计和签发计数的保留天数 | `7` |
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | 全局每小时 Session 签发告警阈值;只告警,不拒绝登录 | `5000` |
| `CRYPTO_SECRET` | 缓存键 HMAC 密钥;共享 Redis 的节点必须使用相同有效值 | 默认跟随 `SESSION_SECRET` |
| `SQL_DSN` | 数据库连接字符串 | - |
| `REDIS_CONN_STRING` | Redis 连接字符串 | - |
| `STREAMING_TIMEOUT` | 流式超时时间(秒) | `300` |
| `STREAM_SCANNER_MAX_BUFFER_MB` | 流式扫描器单行最大缓冲(MB),图像生成等超大 `data:` 片段(如 4K 图片 base64)需适当调大 | `64` |
| `MAX_REQUEST_BODY_MB` | 请求体最大大小(MB,**解压后**计;防止超大请求/zip bomb 导致内存暴涨),超过将返回 `413` | `32` |
| `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | 错误日志开关 | `false` |
| `PYROSCOPE_URL` | Pyroscope 服务地址 | - |
| `PYROSCOPE_APP_NAME` | Pyroscope 应用名 | `new-api` |
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth 用户名 | - |
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Auth 密码 | - |
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex 采样率 | `5` |
| `PYROSCOPE_BLOCK_RATE` | Pyroscope block 采样率 | `5` |
| `HOSTNAME` | Pyroscope 标签里的主机名 | `new-api` |
📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables)
</details>
### 🔧 部署方式
<details>
<summary><strong>方式 1:Docker Compose(推荐)</strong></summary>
```bash ```bash
# 克隆项目 # 仓库根目录
git clone https://github.com/QuantumNous/new-api.git cd web
cd new-api bun install --frozen-lockfile
bun run build
# 编辑配置 cd ..
nano docker-compose.yml go run .
# 启动服务
docker-compose up -d
``` ```
</details> 在另一个终端启动前端开发服务器:
<details>
<summary><strong>方式 2:Docker 命令</strong></summary>
**使用 SQLite:**
```bash ```bash
docker run --name new-api -d --restart always \ cd web
-p 3000:3000 \ bun run dev -- --port 5173
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
``` ```
**使用 MySQL:** 访问 [http://localhost:5173](http://localhost:5173),开发服务器会将 API 请求代理到 3000 端口的后端。需要容器化开发后端时,参阅 [docker-compose.dev.yml](./docker-compose.dev.yml) 和 [makefile](./makefile) 中的 `make dev`。
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 路径说明:** | 目录 | 职责 |
> - `./data:/data` - 相对路径,数据保存在当前目录的 data 文件夹 | --- | --- |
> - 也可使用绝对路径,如:`/your/custom/path:/data` | `router/`、`middleware/`、`controller/` | HTTP 路由、访问校验与 API 处理 |
| `relay/` | 上游适配与请求调度 |
| `service/`、`model/` | 业务逻辑与持久化 |
| [relaykit/](./relaykit/README.md) | 可独立构建的协议 DTO 与转换 Go 模块 |
| [plugins/tasks/](./plugins/tasks/) | JavaScript 任务插件;编写方式与宿主边界见 [Task Plugin API v1](./docs/plugin-api/v1.md) |
| `web/` | Web 控制台,参阅[前端开发约定](./web/AGENTS.md) |
| [electron/](./electron/README.md) | 桌面封装与打包 |
</details> 贡献前请阅读 [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 ./...`。
<details> <a id="documentation"></a>
<summary><strong>方式 3:宝塔面板</strong></summary>
1. 安装宝塔面板(≥ 9.2.0 版本) ## 文档与社区
2. 在应用商店搜索 **New-API**
3. 一键安装
📖 [图文教程](./docs/installation/BT.md) | 资源 | 入口 |
| --- | --- |
| 官方文档 | [使用指南](https://docs.newapi.ai/zh/docs) · [安装部署](https://docs.newapi.ai/zh/docs/installation) · [API 参考](https://docs.newapi.ai/zh/docs/api) |
| 项目导读 | [DeepWiki](https://deepwiki.com/QuantumNous/new-api) |
| 使用问题与交流 | [常见问题](https://docs.newapi.ai/zh/docs/support/faq) · [社区渠道](https://docs.newapi.ai/zh/docs/support/community-interaction) |
| 缺陷与功能建议 | [GitHub Issues](https://github.com/QuantumNous/new-api/issues) |
| 安全漏洞 | 按[安全政策](./.github/SECURITY.md)进行私下报告 |
</details> 反馈问题时请附上版本、部署方式、复现步骤及脱敏日志。欢迎贡献文档、翻译、渠道适配和有针对性的回归测试。
### ⚠️ 多机部署注意事项
> [!WARNING]
> - 所有节点必须使用同一个主数据库,并设置相同的 `SESSION_SECRET`;否则 Access Token、Refresh 会话和临时鉴权流程无法一致校验。
> - 连接同一个 Redis 的节点还必须设置相同的 `CRYPTO_SECRET`,否则节点生成的缓存键摘要不一致,无法正确共享缓存。
登录 Session 和单用户活跃数/签发数限制均以数据库为权威。Redis 中的 Session 仅为短期缓存,TTL 跟随 `SYNC_FREQUENCY`(默认 60 秒),且不会超过 Session 的剩余寿命。
| Redis 拓扑 | Session 状态传播 | 限流语义 |
| --- | --- | --- |
| 所有节点共享 Redis | 撤销和版本发布通常即时传播 | Redis 限流额度在节点间共享 |
| 每个节点使用独立 Redis | 最迟在有效 `SYNC_FREQUENCY` 内回源数据库收敛;版本轮换后,新 Token 在持有旧缓存的节点上可能短暂返回 401 | 每个节点独立计数,集群总额度最坏约为单节点阈值乘以节点数 |
| 不使用 Redis | 每次 Session 校验直接读取数据库 | 各节点使用独立的内存限流额度 |
缩短 `SYNC_FREQUENCY` 可减小独立 Redis 的陈旧窗口,但每个活跃 SID 在每个节点上会按该 TTL 增加一次数据库主键点查。上述保证只让 Session 鉴权在不同拓扑下保持有界陈旧;限流和其他 Redis 控制面缓存仍受拓扑影响。
Token、Origin 校验和 PAT 契约见[用户鉴权与登录会话](./docs/authentication.md)。
### 🔄 渠道重试与缓存
**重试配置:** `设置 → 运营设置 → 通用设置 → 失败重试次数`
**缓存配置:**
- `REDIS_CONN_STRING`:Redis 缓存(推荐)
- `MEMORY_CACHE_ENABLED`:内存缓存
--- ---
@@ -447,36 +318,18 @@ Token、Origin 校验和 PAT 契约见[用户鉴权与登录会话](./docs/authe
--- ---
## 💬 帮助支持
### 📖 文档资源
| 资源 | 链接 |
|------|------|
| 📘 常见问题 | [FAQ](https://docs.newapi.pro/zh/docs/support/faq) |
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/zh/docs/support/community-interaction) |
| 🐛 反馈问题 | [问题反馈](https://docs.newapi.pro/zh/docs/support/feedback-issues) |
| 📚 完整文档 | [官方文档](https://docs.newapi.pro/zh/docs) |
### 🤝 贡献指南
欢迎各种形式的贡献!
- 🐛 报告 Bug
- 💡 提出新功能
- 📝 改进文档
- 🔧 提交代码
---
## 📜 许可证 ## 📜 许可证
本项目采用 [GNU Affero 通用公共许可证 v3.0 (AGPLv3)](./LICENSE) 授权。 本项目采用 [GNU Affero 通用公共许可证 v3.0 (AGPLv3)](./LICENSE) 授权。
根据 AGPLv3 第 7 条,本项目还适用[附加条款](./NOTICE)。修改版本必须在适当法律声明及界面中显著的关于、法律、页脚或署名位置保留作者署名 `Frontend design and development by New API contributors.`,并保留指向原项目 <https://github.com/QuantumNous/new-api> 的可见链接。
本项目为开源项目,在 [One API](https://github.com/songquanpeng/one-api)(MIT 许可证)的基础上进行二次开发。 本项目为开源项目,在 [One API](https://github.com/songquanpeng/one-api)(MIT 许可证)的基础上进行二次开发。
如果您所在的组织政策不允许使用 AGPLv3 许可的软件,或您希望规避 AGPLv3 的开源义务,请发送邮件至:[support@quantumnous.com](mailto:support@quantumnous.com) 如果您所在的组织政策不允许使用 AGPLv3 许可的软件,或您希望规避 AGPLv3 的开源义务,请发送邮件至:[support@quantumnous.com](mailto:support@quantumnous.com)
署名及依赖声明见 [NOTICE](./NOTICE) 和[第三方许可证](./THIRD-PARTY-LICENSES.md)。
--- ---
## 🌟 Star History ## 🌟 Star History
@@ -495,7 +348,7 @@ Token、Origin 校验和 PAT 契约见[用户鉴权与登录会话](./docs/authe
如果这个项目对你有帮助,欢迎给我们一个 ⭐️ Star! 如果这个项目对你有帮助,欢迎给我们一个 ⭐️ Star!
**[官方文档](https://docs.newapi.pro/zh/docs)** • **[问题反馈](https://github.com/Calcium-Ion/new-api/issues)** • **[最新发布](https://github.com/Calcium-Ion/new-api/releases)** **[官方文档](https://docs.newapi.ai/zh/docs)** • **[问题反馈](https://github.com/Calcium-Ion/new-api/issues)** • **[最新发布](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Built with ❤️ by QuantumNous</sub> <sub>Built with ❤️ by QuantumNous</sub>
+157 -319
View File
@@ -4,11 +4,11 @@
# New API # New API
🍥 **新一代大模型網關與AI資產管理系統** **連接模型、應用程式與 Agent 的 AI 閘道**
<p align="center"> <p align="center">
繁體中文 |
<a href="./README.zh_CN.md">简体中文</a> | <a href="./README.zh_CN.md">简体中文</a> |
<strong>繁體中文</strong> |
<a href="./README.md">English</a> | <a href="./README.md">English</a> |
<a href="./README.fr.md">Français</a> | <a href="./README.fr.md">Français</a> |
<a href="./README.ja.md">日本語</a> <a href="./README.ja.md">日本語</a>
@@ -43,23 +43,34 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#-快速開始">快速開始</a> • <a href="#capabilities">核心能力</a> •
<a href="#-主要特性">主要特性</a> • <a href="#quick-start">快速開始</a> •
<a href="#-部署">部署</a> • <a href="#deployment">部署維運</a> •
<a href="#-文件">文件</a> • <a href="#development">開發擴充</a> •
<a href="#-幫助支援">幫助</a> <a href="#documentation">文件社群</a>
</p> </p>
</div> </div>
---
## 📝 項目說明 ## 📝 項目說明
New API 是面向應用程式、Agent 和團隊的自託管 AI 閘道。將不同供應商的模型服務接入統一入口,在同一套控制台中管理渠道、存取權限、用量與成本。
你可以用它為團隊分配已授權的模型資源,在切換上游時減少用戶端改動,或建置自己的多模型服務。支援接入 OpenAI、Anthropic、Google Gemini、Azure OpenAI、AWS Bedrock、Vertex AI、DeepSeek、通義千問及其他相容服務。
> [!IMPORTANT] > [!IMPORTANT]
> - 本專案僅面向合法授權的 AI API 閘道、組織內部鑑權、多模型管理、用量統計、成本核算和私有化部署場景。 > - 本專案僅面向合法授權的 AI API 閘道、組織內部鑑權、多模型管理、用量統計、成本核算和私有化部署場景。
> - 使用者必須合法取得上游 API Key、帳號、模型服務或介面權限,並遵守上游服務條款及適用法律法規。 > - 使用者必須合法取得上游 API Key、帳號、模型服務或介面權限,並遵守上游服務條款及適用法律法規。
> - 使用者應確保其使用方式符合上游服務條款及適用法律法規。 > - 使用者應確保其使用方式符合上游服務條款及適用法律法規。
> - 面向公眾提供生成式人工智慧服務時,使用者應遵守[《生成式人工智慧服務管理暫行辦法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)等監管要求,自行完成所在司法轄區要求的備案、許可、內容安全、實名、日誌留存、稅務和上游授權等合規義務。 > - 面向公眾提供生成式人工智慧服務時,使用者應遵守[《生成式人工智慧服務管理暫行辦法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)等監管要求,自行完成所在司法轄區要求的備案、許可、內容安全、實名、日誌留存、稅務和上游授權等合規義務。
<!-- -->
> [!WARNING]
> 將本專案作為面向公眾的生成式 AI 服務或 API 轉售服務運營時,使用者應先完成備案、內容安全、實名、日誌留存、稅務、支付和上游授權等合規義務。
--- ---
## 🤝 我們信任的合作伙伴 ## 🤝 我們信任的合作伙伴
@@ -78,9 +89,6 @@
--><a href="https://bda.pku.edu.cn/" target="_blank"> --><a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="北京大學" height="80" /> <img src="./docs/images/pku.png" alt="北京大學" height="80" />
</a><!-- </a><!--
--><a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="80" />
</a><!--
--><a href="https://www.aliyun.com/" target="_blank"> --><a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="阿里雲" height="80" /> <img src="./docs/images/aliyun.png" alt="阿里雲" height="80" />
</a><!-- </a><!--
@@ -105,326 +113,174 @@
--- ---
## 🚀 快速開始 <a id="capabilities"></a>
### 使用 Docker Compose(推薦) ## 核心能力
| 方向 | 可以做什麼 |
| --- | --- |
| 模型接入 | 支援 OpenAI Chat Completions、Responses、Anthropic Messages 和 Gemini 協定,以及上游支援的串流輸出、工具呼叫、推理與多模態輸入 |
| 渠道調度 | 設定模型映射、渠道優先順序與權重、失敗重試、渠道親和性和多金鑰管理 |
| 用量與成本 | 管理額度、訂閱方案、用量日誌、快取計費,以及基於運算式的階梯定價 |
| 存取控制 | 管理使用者、群組、細粒度權限和 API Key 限制;支援 OAuth/OIDC、通行密鑰、兩步驟驗證與登入工作階段管理 |
| 非同步任務 | 透過 JavaScript 外掛擴充圖片、影片等任務 API,統一查詢任務狀態和取得產物 |
| Web 控制台 | 設定渠道與模型、查看用量和稽核日誌、在 Playground 中測試模型;支援簡體中文、繁體中文、英語、法語、日語、俄語和越南語 |
### 協定與介面
| 介面類型 | 常用入口 |
| --- | --- |
| OpenAI Chat / Responses | `POST /v1/chat/completions`、`POST /v1/responses` |
| Anthropic Messages | `POST /v1/messages` |
| Gemini | `POST /v1beta/models/{model}:generateContent`、`POST /v1beta/models/{model}:streamGenerateContent` |
| Realtime / Responses WebSocket | `GET /v1/realtime`、`GET /v1/responses`(WebSocket 升級) |
| 圖片 / 音訊 | `/v1/images/generations`、`/v1/images/edits`、`/v1/audio/speech`、`/v1/audio/transcriptions`、`/v1/audio/translations` |
| 向量 / 重排 | `POST /v1/embeddings`、`POST /v1/rerank` |
| 任務外掛 | `POST /v1/tasks/{pluginKey}`、`GET /v1/tasks/{taskId}`,以及各外掛宣告的協定路由 |
[RelayKit](./relaykit/README.md) 提供上述四種文字協定之間的請求、回應和串流轉換。實際可用能力取決於渠道、上游模型和轉換路徑;協定特有的工具與欄位可能無法完整映射。WebSocket 同樣需要上游與渠道設定支援。
本 README 描述目前原始碼的能力,部署時請同時查看所選版本的發行說明。
<a id="quick-start"></a>
## 快速開始
### 使用 Docker 本機體驗
以下指令使用 SQLite 啟動單一執行個體,僅監聽本機連接埠:
```bash
mkdir -p data
docker run --name new-api -d --restart unless-stopped \
-p 127.0.0.1:3000:3000 \
-e TZ=Asia/Shanghai \
-v "$(pwd)/data:/data" \
calciumion/new-api:latest
```
開啟 [http://localhost:3000](http://localhost:3000),依初始化精靈建立管理員帳號。SQLite 資料庫存放在掛載的 `data` 目錄中,更換容器後仍會保留。
### 發起第一次請求
1. 新增渠道,填寫上游 API Key、可用模型和所屬群組,執行渠道測試。
2. 設定模型價格,確保呼叫使用者有可用額度或有效訂閱。
3. 在控制台建立 API Key,確保它能存取對應群組和模型。
4. 對於 OpenAI 相容用戶端,將 Base URL 設為 `http://localhost:3000/v1`,金鑰使用 **New API 簽發的 Key**。
在終端機中將 `NEW_API_KEY` 環境變數設為該金鑰,查詢它可以存取的模型:
```bash
curl --fail-with-body http://localhost:3000/v1/models \
-H "Authorization: Bearer ${NEW_API_KEY}"
```
然後呼叫 Responses,將 `your-enabled-model` 替換為已啟用且支援該介面的模型名稱:
```bash
curl --fail-with-body http://localhost:3000/v1/responses \
-H "Authorization: Bearer ${NEW_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"model":"your-enabled-model","input":"Hello!"}'
```
<a id="deployment"></a>
## 部署與維運
### Docker Compose
儲存庫的 [Compose 設定](./docker-compose.yml) 預設啟動 **New API + PostgreSQL + Redis**,並提供 MySQL 和獨立 ClickHouse 日誌資料庫的設定範例。
```bash ```bash
# 複製項目
git clone https://github.com/QuantumNous/new-api.git git clone https://github.com/QuantumNous/new-api.git
cd new-api cd new-api
# 編輯 docker-compose.yml 配置
nano docker-compose.yml
# 啟動服務
docker-compose up -d
``` ```
<details> 啟動前編輯 `docker-compose.yml`:同時替換資料庫、Redis 服務及對應連線字串中的範例密碼,並設定固定的隨機 `SESSION_SECRET`(可用 `openssl rand -hex 32` 產生)。透過 HTTPS 存取控制台時,設定 `SESSION_COOKIE_SECURE=true`,並在 `SESSION_COOKIE_TRUSTED_URL` 中填寫控制台對外的精確 HTTPS Origin。
<summary><strong>使用 Docker 命令</strong></summary>
```bash ```bash
# 拉取最新鏡像 docker compose up -d
docker pull calciumion/new-api:latest docker compose logs -f new-api
# 使用 SQLite(預設)
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# 使用 MySQL
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
``` ```
> **💡 提示:** `-v ./data:/data` 會將數據保存在當前目錄的 `data` 資料夾中,你也可以改為絕對路徑如 `-v /your/custom/path:/data` ### 儲存與設定
</details> | 元件 | 可選方案 |
| --- | --- |
| 主資料庫 | SQLite、MySQL ≥ 5.7.8、PostgreSQL ≥ 9.6 |
| 獨立日誌資料庫 | 透過 `LOG_SQL_DSN` 設定,額外支援 ClickHouse |
| 快取 | 可選 Redis 與記憶體快取;多節點需要共享限流額度時使用共享 Redis |
| 容器平台 | Linux amd64 / arm64 |
--- | 環境變數 | 用途 |
| --- | --- |
| `SQL_DSN` | 主資料庫連線字串;未設定時使用 SQLite |
| `LOG_SQL_DSN` | 可選的獨立日誌資料庫連線字串 |
| `REDIS_CONN_STRING` | Redis 連線字串 |
| `SESSION_SECRET` | 持久保存的驗證金鑰,所有節點必須一致 |
| `CRYPTO_SECRET` | 預設使用 `SESSION_SECRET`;共享 Redis 的節點必須使用相同的有效值 |
| `SESSION_COOKIE_SECURE` | HTTPS 控制台設為 `true`,啟用 Secure 更新 Cookie 和嚴格的更新/登出來源驗證 |
| `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:以逗號分隔的精確 HTTPS Origin,不含路徑或萬用字元;本機 HTTP 模式不設定 |
| `TRUSTED_PROXIES` | 可信反向代理 IP/CIDR,或 `none`;依實際網路明確設定 |
🎉 部署完成後,訪問 `http://localhost:3000` 即可使用! 完整設定見[環境變數範例](./.env.example)、[環境變數文件](https://docs.newapi.ai/zh/docs/installation/config-maintenance/environment-variables)和[驗證與登入工作階段說明](./docs/authentication.md)。容器變數應透過 Compose 的 `environment` 或 `env_file` 注入;只複製 `.env.example` 不會自動將變數傳入容器。
> [!WARNING] 正式部署時使用 HTTPS,並設定反向代理支援串流回應與 WebSocket 升級。持久化並備份資料庫和掛載資料。多節點必須共用主資料庫和驗證金鑰;獨立 Redis 或記憶體限流器會按節點分別計數。不同拓撲下的工作階段傳播行為見驗證文件。
> 將本專案作為面向公眾的生成式 AI 服務或 API 轉售服務運營時,使用者應先完成備案、內容安全、實名、日誌留存、稅務、支付和上游授權等合規義務。
📖 更多部署方式請參考 [部署指南](https://docs.newapi.pro/zh/docs/installation) 從[發行頁](https://github.com/QuantumNous/new-api/releases)選擇明確的映像版本,閱讀升級說明並先備份再升級。`latest` 會隨發行建置變動;既有執行個體應依實際版本評估遷移與相容性。
--- <a id="development"></a>
## 📚 文件 ## 開發與擴充
<div align="center"> 後端使用 Go 和 Gin;控制台使用 React 19、TypeScript、Rsbuild、TanStack 與 Tailwind CSS 4。前端相依套件和指令碼使用 Bun;Go 語言基線見 [go.mod](./go.mod),容器建置工具鏈見 [Dockerfile](./Dockerfile)。
### 📖 [官方文件](https://docs.newapi.pro/zh/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) 後端會嵌入 `web/dist`,首次啟動前先建置前端:
</div>
**快速導航:**
| 分類 | 連結 |
|------|------|
| 🚀 部署指南 | [安裝文件](https://docs.newapi.pro/zh/docs/installation) |
| ⚙️ 環境配置 | [環境變數](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables) |
| 📡 接口文件 | [API 文件](https://docs.newapi.pro/zh/docs/api) |
| ❓ 常見問題 | [FAQ](https://docs.newapi.pro/zh/docs/support/faq) |
| 💬 社群交流 | [交流管道](https://docs.newapi.pro/zh/docs/support/community-interaction) |
---
## ✨ 主要特性
> 詳細特性請參考 [特性說明](https://docs.newapi.pro/zh/docs/guide/wiki/basic-concepts/features-introduction)
### 🎨 核心功能
| 特性 | 說明 |
|------|------|
| 🎨 全新 UI | 現代化的用戶界面設計 |
| 🌍 多語言 | 支援簡體中文、繁體中文、英文、法語、日語 |
| 🔄 數據兼容 | 完全兼容原版 One API 資料庫 |
| 📈 數據看板 | 視覺化控制檯與統計分析 |
| 🔒 權限管理 | 令牌分組、模型限制、用戶管理 |
### 💰 授權用量與成本管理
- ✅ 合法授權場景下的內部儲值與額度分配(易支付、Stripe)
- ✅ 組織內按次、按量或快取命中成本核算
- ✅ 支援 OpenAI、Azure、DeepSeek、Claude、Qwen 等模型的快取計費統計
- ✅ 面向內部管理或企業客戶的靈活計費策略配置
### 🔐 授權與安全
- 😈 Discord 授權登錄
- 🤖 LinuxDO 授權登錄
- 📱 Telegram 授權登錄
- 🔑 OIDC 統一認證
- 🔍 Key 查詢使用額度(配合 [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
### 🚀 高級功能
**API 格式支援:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-response)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/create-realtime-session)(含 Azure)
- ⚡ [Claude Messages](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message)
- ⚡ [Google Gemini](https://doc.newapi.pro/api/google-gemini-chat)
- 🔄 [Rerank 模型](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank)(Cohere、Jina)
**智慧路由:**
- ⚖️ 管道加權隨機
- 🔄 失敗自動重試
- 🚦 用戶級別模型限流
**格式轉換:**
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
- 🔄 **OpenAI Compatible → Google Gemini**
- 🔄 **Google Gemini → OpenAI Compatible** - 僅支援文本,暫不支援函數調用
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - 開發中
- 🔄 **思考轉內容功能**
**Reasoning Effort 支援:**
<details>
<summary>查看詳細配置</summary>
**OpenAI 系列模型:**
- `o3-mini-high` - High reasoning effort
- `o3-mini-medium` - Medium reasoning effort
- `o3-mini-low` - Low reasoning effort
- `gpt-5-high` - High reasoning effort
- `gpt-5-medium` - Medium reasoning effort
- `gpt-5-low` - Low reasoning effort
**Claude 思考模型:**
- `claude-3-7-sonnet-20250219-thinking` - 啟用思考模式
**Google Gemini 系列模型:**
- `gemini-2.5-flash-thinking` - 啟用思考模式
- `gemini-2.5-flash-nothinking` - 禁用思考模式
- `gemini-2.5-pro-thinking` - 啟用思考模式
- `gemini-2.5-pro-thinking-128` - 啟用思考模式,並設置思考預算為128tokens
- 也可以直接在 Gemini 模型名稱後追加 `-low` / `-medium` / `-high` 來控制思考力道(無需再設置思考預算後綴)
</details>
---
## 🤖 模型支援
> 詳情請參考 [接口文件 - 閘道接口](https://docs.newapi.pro/zh/docs/api)
| 模型類型 | 說明 | 文件 |
|---------|------|------|
| 🤖 OpenAI-Compatible | OpenAI 兼容模型 | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createchatcompletion) |
| 🤖 OpenAI Responses | OpenAI Responses 格式 | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createresponse) |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文件](https://doc.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文件](https://doc.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere、Jina | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank) |
| 💬 Claude | Messages 格式 | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/chat/createmessage) |
| 🌐 Gemini | Google Gemini 格式 | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
| 🔧 Dify | ChatFlow 模式 | - |
| 🎯 自訂上游 | 支援配置合法授權的上游介面位址 | - |
### 📡 支援的接口
<details>
<summary>查看完整接口列表</summary>
- [聊天接口 (Chat Completions)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createchatcompletion)
- [響應接口 (Responses)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/createresponse)
- [圖像接口 (Image)](https://docs.newapi.pro/zh/docs/api/ai-model/images/openai/post-v1-images-generations)
- [音訊接口 (Audio)](https://docs.newapi.pro/zh/docs/api/ai-model/audio/openai/create-transcription)
- [影片接口 (Video)](https://docs.newapi.pro/zh/docs/api/ai-model/videos/sora/createvideo)
- [嵌入接口 (Embeddings)](https://docs.newapi.pro/zh/docs/api/ai-model/embeddings/createembedding)
- [重排序接口 (Rerank)](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/creatererank)
- [即時對話 (Realtime)](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/createrealtimesession)
- [Claude 聊天](https://docs.newapi.pro/zh/docs/api/ai-model/chat/createmessage)
- [Google Gemini 聊天](https://docs.newapi.pro/zh/docs/api/ai-model/chat/gemini/geminirelayv1beta)
</details>
---
## 🚢 部署
> [!TIP]
> **最新版 Docker 鏡像:** `calciumion/new-api:latest`
### 📋 部署要求
| 組件 | 要求 |
|------|------|
| **本地資料庫** | SQLite(Docker 需掛載 `/data` 目錄)|
| **遠端資料庫** | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6 |
| **容器引擎** | Docker / Docker Compose |
| **系統架構** | 僅支援 64 位元系統(amd64 / arm64),不支援 32 位元系統 |
### ⚙️ 環境變數配置
<details>
<summary>常用環境變數配置</summary>
| 變數名 | 說明 | 預設值 |
|--------|--------------------------------------------------------------|--------|
| `SESSION_SECRET` | 鑑權簽章密鑰;所有節點必須保持一致 | - |
| `SESSION_COOKIE_SECURE` | `false`/未設定時關閉 refresh/logout OriginGuard 以相容本機 HTTP 開發代理;`true` 時啟用 Secure Cookie 和嚴格 Origin 驗證 | `false` |
| `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:允許呼叫 refresh/logout 的精確 HTTPS Origin,多個值以英文逗號分隔;不是 relay CORS 白名單 | - |
| `TRUSTED_PROXIES` | 未設定/留空時信任本機回送、RFC1918 和 IPv6 ULA 並輸出啟動警告;`none` 不信任任何代理;明確指定的代理 IP/CIDR 清單會完整取代預設值 | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
| `USER_SESSION_ACTIVE_LIMIT` | 單一用戶最大活躍登入 Session 數 | `50` |
| `USER_SESSION_ISSUANCE_LIMIT` | 單一用戶在簽發視窗內可建立的 Session 總數,包含已撤銷 Session | `100` |
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 簽發計數視窗(秒);高於 revoked 保留期時自動限制 | `86400` |
| `USER_SESSION_REVOKED_RETENTION_DAYS` | revoked Session 用於稽核與簽發計數的保留天數 | `7` |
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | 全域每小時 Session 簽發告警門檻;只告警,不拒絕登入 | `5000` |
| `CRYPTO_SECRET` | 快取鍵 HMAC 密鑰;共用 Redis 的節點必須使用相同有效值 | 預設跟隨 `SESSION_SECRET` |
| `SQL_DSN` | 資料庫連接字符串 | - |
| `REDIS_CONN_STRING` | Redis 連接字符串 | - |
| `STREAMING_TIMEOUT` | 流式超時時間(秒) | `300` |
| `STREAM_SCANNER_MAX_BUFFER_MB` | 流式掃描器單行最大緩衝(MB),圖像生成等超大 `data:` 片段(如 4K 圖片 base64)需適當調大 | `64` |
| `MAX_REQUEST_BODY_MB` | 請求體最大大小(MB,**解壓縮後**計;防止超大請求/zip bomb 導致記憶體暴漲),超過將返回 `413` | `32` |
| `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | 錯誤日誌開關 | `false` |
| `PYROSCOPE_URL` | Pyroscope 服務位址 | - |
| `PYROSCOPE_APP_NAME` | Pyroscope 應用名 | `new-api` |
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth 用戶名 | - |
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Auth 密碼 | - |
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex 採樣率 | `5` |
| `PYROSCOPE_BLOCK_RATE` | Pyroscope block 採樣率 | `5` |
| `HOSTNAME` | Pyroscope 標籤裡的主機名 | `new-api` |
📖 **完整配置:** [環境變數文件](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables)
</details>
### 🔧 部署方式
<details>
<summary><strong>方式 1:Docker Compose(推薦)</strong></summary>
```bash ```bash
# 複製項目 # 儲存庫根目錄
git clone https://github.com/QuantumNous/new-api.git cd web
cd new-api bun install --frozen-lockfile
bun run build
# 編輯配置 cd ..
nano docker-compose.yml go run .
# 啟動服務
docker-compose up -d
``` ```
</details> 在另一個終端機啟動前端開發伺服器:
<details>
<summary><strong>方式 2:Docker 命令</strong></summary>
**使用 SQLite:**
```bash ```bash
docker run --name new-api -d --restart always \ cd web
-p 3000:3000 \ bun run dev -- --port 5173
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
``` ```
**使用 MySQL:** 存取 [http://localhost:5173](http://localhost:5173),開發伺服器會將 API 請求代理到 3000 連接埠的後端。需要容器化開發後端時,參閱 [docker-compose.dev.yml](./docker-compose.dev.yml) 和 [makefile](./makefile) 中的 `make dev`。
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 路徑說明:** | 目錄 | 職責 |
> - `./data:/data` - 相對路徑,數據保存在當前目錄的 data 資料夾 | --- | --- |
> - 也可使用絕對路徑,如:`/your/custom/path:/data` | `router/`、`middleware/`、`controller/` | HTTP 路由、存取驗證與 API 處理 |
| `relay/` | 上游介接與請求調度 |
| `service/`、`model/` | 業務邏輯與持久化 |
| [relaykit/](./relaykit/README.md) | 可獨立建置的協定 DTO 與轉換 Go 模組 |
| [plugins/tasks/](./plugins/tasks/) | JavaScript 任務外掛;編寫方式與宿主邊界見 [Task Plugin API v1](./docs/plugin-api/v1.md) |
| `web/` | Web 控制台,參閱[前端開發約定](./web/AGENTS.md) |
| [electron/](./electron/README.md) | 桌面封裝與打包 |
</details> 貢獻前請閱讀 [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 ./...`。
<details> <a id="documentation"></a>
<summary><strong>方式 3:寶塔面板</strong></summary>
1. 安裝寶塔面板(≥ 9.2.0 版本) ## 文件與社群
2. 在應用商店搜尋 **New-API**
3. 一鍵安裝
📖 [圖文教學](./docs/BT.md) | 資源 | 入口 |
| --- | --- |
| 官方文件 | [使用指南](https://docs.newapi.ai/zh/docs) · [安裝部署](https://docs.newapi.ai/zh/docs/installation) · [API 參考](https://docs.newapi.ai/zh/docs/api) |
| 專案導讀 | [DeepWiki](https://deepwiki.com/QuantumNous/new-api) |
| 使用問題與交流 | [常見問題](https://docs.newapi.ai/zh/docs/support/faq) · [社群管道](https://docs.newapi.ai/zh/docs/support/community-interaction) |
| 缺陷與功能建議 | [GitHub Issues](https://github.com/QuantumNous/new-api/issues) |
| 安全漏洞 | 依[安全政策](./.github/SECURITY.md)私下回報 |
</details> 回報問題時請附上版本、部署方式、重現步驟及去識別化日誌。歡迎貢獻文件、翻譯、渠道介接和有針對性的回歸測試。
### ⚠️ 多機部署注意事項
> [!WARNING]
> - 所有節點必須使用同一個主資料庫,並設定相同的 `SESSION_SECRET`;否則 Access Token、Refresh 工作階段和臨時鑑權流程無法一致驗證。
> - 連線至同一個 Redis 的節點還必須設定相同的 `CRYPTO_SECRET`,否則節點產生的快取鍵摘要不一致,無法正確共用快取。
登入 Session 和單一使用者的活躍數/簽發數限制均以資料庫為權威。Redis 中的 Session 僅為短期快取,TTL 跟隨 `SYNC_FREQUENCY`(預設 60 秒),且不會超過 Session 的剩餘有效期。
| Redis 拓撲 | Session 狀態傳播 | 限流語義 |
| --- | --- | --- |
| 所有節點共用 Redis | 撤銷和版本發布通常即時傳播 | Redis 限流額度在節點間共用 |
| 每個節點使用獨立 Redis | 最遲在有效 `SYNC_FREQUENCY` 內回源資料庫並收斂;版本輪換後,新 Token 在持有舊快取的節點上可能短暫傳回 401 | 每個節點獨立計數,叢集總額度最壞約為單一節點門檻乘以節點數 |
| 不使用 Redis | 每次 Session 驗證都直接讀取資料庫 | 各節點使用獨立的記憶體限流額度 |
縮短 `SYNC_FREQUENCY` 可減少獨立 Redis 的陳舊視窗,但每個活躍 SID 在每個節點上會依該 TTL 增加一次資料庫主鍵查詢。上述保證只讓 Session 鑑權在不同拓撲下維持有界陳舊;限流和其他 Redis 控制面快取仍受拓撲影響。
Token、Origin 驗證和 PAT 契約請參閱[使用者鑑權與登入工作階段](./docs/authentication.md)。
### 🔄 管道重試與快取
**重試配置:** `設置 → 運營設置 → 通用設置 → 失敗重試次數`
**快取配置:**
- `REDIS_CONN_STRING`:Redis 快取(推薦)
- `MEMORY_CACHE_ENABLED`:記憶體快取
--- ---
@@ -446,36 +302,18 @@ Token、Origin 驗證和 PAT 契約請參閱[使用者鑑權與登入工作階
--- ---
## 💬 幫助支援
### 📖 文件資源
| 資源 | 連結 |
|------|------|
| 📘 常見問題 | [FAQ](https://docs.newapi.pro/zh/docs/support/faq) |
| 💬 社群交流 | [交流管道](https://docs.newapi.pro/zh/docs/support/community-interaction) |
| 🐛 回饋問題 | [問題回饋](https://docs.newapi.pro/zh/docs/support/feedback-issues) |
| 📚 完整文件 | [官方文件](https://docs.newapi.pro/zh/docs) |
### 🤝 貢獻指南
歡迎各種形式的貢獻!
- 🐛 報告 Bug
- 💡 提出新功能
- 📝 改進文件
- 🔧 提交程式碼
---
## 📜 許可證 ## 📜 許可證
本項目採用 [GNU Affero 通用公共許可證 v3.0 (AGPLv3)](./LICENSE) 授權。 本項目採用 [GNU Affero 通用公共許可證 v3.0 (AGPLv3)](./LICENSE) 授權。
依據 AGPLv3 第 7 條,本專案另適用[附加條款](./NOTICE)。修改版本必須在適當法律聲明及介面中顯著的關於、法律、頁尾或署名位置保留作者署名 `Frontend design and development by New API contributors.`,並保留指向原專案 <https://github.com/QuantumNous/new-api> 的可見連結。
本項目為開源項目,在 [One API](https://github.com/songquanpeng/one-api)(MIT 許可證)的基礎上進行二次開發。 本項目為開源項目,在 [One API](https://github.com/songquanpeng/one-api)(MIT 許可證)的基礎上進行二次開發。
如果您所在的組織政策不允許使用 AGPLv3 許可的軟體,或您希望規避 AGPLv3 的開源義務,請發送郵件至:[support@quantumnous.com](mailto:support@quantumnous.com) 如果您所在的組織政策不允許使用 AGPLv3 許可的軟體,或您希望規避 AGPLv3 的開源義務,請發送郵件至:[support@quantumnous.com](mailto:support@quantumnous.com)
署名及相依套件聲明見 [NOTICE](./NOTICE) 和[第三方授權條款](./THIRD-PARTY-LICENSES.md)。
--- ---
## 🌟 Star History ## 🌟 Star History
@@ -494,7 +332,7 @@ Token、Origin 驗證和 PAT 契約請參閱[使用者鑑權與登入工作階
如果這個項目對你有幫助,歡迎給我們一個 ⭐️ Star! 如果這個項目對你有幫助,歡迎給我們一個 ⭐️ Star!
**[官方文件](https://docs.newapi.pro/zh/docs)** • **[問題回饋](https://github.com/Calcium-Ion/new-api/issues)** • **[最新發布](https://github.com/Calcium-Ion/new-api/releases)** **[官方文件](https://docs.newapi.ai/zh/docs)** • **[問題回饋](https://github.com/Calcium-Ion/new-api/issues)** • **[最新發布](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Built with ❤️ by QuantumNous</sub> <sub>Built with ❤️ by QuantumNous</sub>
+168 -141
View File
@@ -3,147 +3,171 @@
This file summarizes direct third-party dependencies used by distributed builds of this project. This file summarizes direct third-party dependencies used by distributed builds of this project.
It is an engineering compliance artifact and should be kept with Docker images, standalone binaries, frontend bundles, and Electron installers. It is an engineering compliance artifact and should be kept with Docker images, standalone binaries, frontend bundles, and Electron installers.
Scope: direct dependencies from `go.mod`, `web/package.json`, and `electron/package.json`. Scope: direct dependencies from `go.mod` (including the in-repo `relaykit` and `tokenkit` modules), `web/package.json`, and `electron/package.json`.
Transitive dependencies should be audited before a final external release. Transitive dependencies should be audited before a final external release.
## Dependency Inventory ## Dependency Inventory
| Area | Scope | Ecosystem | Dependency | Version | License | | Area | Scope | Ecosystem | Dependency | Version | License |
|-------------|-------------|-----------|-------------------------------------------------------|--------------------------------------|----------------------------------------------------| |----------|-------------|-----------|---------------------------------------------------------|--------------------------------------|-------------------------|
| backend | production | Go | `github.com/Calcium-Ion/go-epay` | `v0.0.4` | Proprietary/Internal - owned by project maintainer | | backend | production | Go | `github.com/Azure/go-ntlmssp` | `v0.1.1` | MIT |
| backend | production | Go | `github.com/abema/go-mp4` | `v1.4.1` | MIT | | backend | production | Go | `github.com/Calcium-Ion/go-epay` | `v0.0.5` | MIT |
| backend | test | Go | `github.com/alicebob/miniredis/v2` | `v2.38.0` | MIT | | backend | production | Go | `github.com/Calcium-Ion/moejs` | `v0.1.0-alpha.7` | Apache-2.0 |
| backend | production | Go | `github.com/andybalholm/brotli` | `v1.1.1` | MIT | | backend | production | Go | `github.com/ClickHouse/clickhouse-go/v2` | `v2.46.0` | Apache-2.0 |
| backend | production | Go | `github.com/anknown/ahocorasick` | `v0.0.0-20190904063843-d75dbd5169c0` | MIT | | backend | production | Go | `github.com/abema/go-mp4` | `v1.4.1` | MIT |
| backend | production | Go | `github.com/aws/aws-sdk-go-v2` | `v1.41.5` | Apache-2.0 | | backend | test | Go | `github.com/alicebob/miniredis/v2` | `v2.38.0` | MIT |
| backend | production | Go | `github.com/aws/aws-sdk-go-v2/credentials` | `v1.19.10` | Apache-2.0 | | backend | production | Go | `github.com/andybalholm/brotli` | `v1.2.0` | MIT |
| backend | production | Go | `github.com/aws/aws-sdk-go-v2/service/bedrockruntime` | `v1.50.4` | Apache-2.0 | | backend | production | Go | `github.com/anknown/ahocorasick` | `v0.0.0-20190904063843-d75dbd5169c0` | MIT |
| backend | production | Go | `github.com/aws/smithy-go` | `v1.24.2` | Apache-2.0 | | backend | production | Go | `github.com/aws/aws-sdk-go-v2` | `v1.41.5` | Apache-2.0 |
| backend | production | Go | `github.com/bytedance/gopkg` | `v0.1.3` | Apache-2.0 | | backend | production | Go | `github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream` | `v1.7.8` | Apache-2.0 |
| backend | production | Go | `github.com/gin-contrib/cors` | `v1.7.2` | MIT | | backend | production | Go | `github.com/aws/aws-sdk-go-v2/credentials` | `v1.19.10` | Apache-2.0 |
| backend | production | Go | `github.com/gin-contrib/gzip` | `v0.0.6` | MIT | | backend | production | Go | `github.com/aws/aws-sdk-go-v2/service/bedrockruntime` | `v1.50.4` | Apache-2.0 |
| backend | production | Go | `github.com/gin-contrib/static` | `v0.0.1` | MIT | | backend | production | Go | `github.com/aws/smithy-go` | `v1.24.2` | Apache-2.0 |
| backend | production | Go | `github.com/gin-gonic/gin` | `v1.9.1` | MIT | | backend | production | Go | `github.com/bytedance/gopkg` | `v0.1.3` | Apache-2.0 |
| backend | production | Go | `github.com/glebarez/sqlite` | `v1.9.0` | MIT | | backend | production | Go | `github.com/casbin/casbin/v2` | `v2.135.0` | Apache-2.0 |
| backend | production | Go | `github.com/go-audio/aiff` | `v1.1.0` | Apache-2.0 | | backend | production | Go | `github.com/coreos/go-oidc/v3` | `v3.21.0` | Apache-2.0 |
| backend | production | Go | `github.com/go-audio/wav` | `v1.1.0` | Apache-2.0 | | backend | production | Go | `github.com/expr-lang/expr` | `v1.17.8` | MIT |
| backend | production | Go | `github.com/go-playground/validator/v10` | `v10.20.0` | MIT | | backend | production | Go | `github.com/fxamacker/cbor/v2` | `v2.9.0` | MIT |
| backend | production | Go | `github.com/go-redis/redis/v8` | `v8.11.5` | BSD-2-Clause | | backend | production | Go | `github.com/gin-contrib/cors` | `v1.7.2` | MIT |
| backend | production | Go | `github.com/go-webauthn/webauthn` | `v0.14.0` | BSD-3-Clause | | backend | production | Go | `github.com/gin-contrib/gzip` | `v0.0.6` | MIT |
| backend | production | Go | `github.com/golang-jwt/jwt/v5` | `v5.3.0` | MIT | | backend | production | Go | `github.com/gin-contrib/static` | `v0.0.1` | MIT |
| backend | production | Go | `github.com/google/uuid` | `v1.6.0` | BSD-3-Clause | | backend | production | Go | `github.com/gin-gonic/gin` | `v1.9.1` | MIT |
| backend | production | Go | `github.com/gorilla/websocket` | `v1.5.0` | BSD-2-Clause | | backend | production | Go | `github.com/glebarez/go-sqlite` | `v1.21.2` | BSD-3-Clause |
| backend | production | Go | `github.com/grafana/pyroscope-go` | `v1.2.7` | Apache-2.0 | | backend | production | Go | `github.com/glebarez/sqlite` | `v1.11.0` | MIT |
| backend | production | Go | `github.com/grafana/sobek` | `v0.0.0-20260708062710-267a0e055bb4` | MIT | | backend | production | Go | `github.com/go-audio/aiff` | `v1.1.0` | Apache-2.0 |
| backend | production | Go | `github.com/jfreymuth/oggvorbis` | `v1.0.5` | MIT | | backend | production | Go | `github.com/go-audio/wav` | `v1.1.0` | Apache-2.0 |
| backend | production | Go | `github.com/jinzhu/copier` | `v0.4.0` | MIT | | backend | production | Go | `github.com/go-playground/validator/v10` | `v10.20.0` | MIT |
| backend | production | Go | `github.com/joho/godotenv` | `v1.5.1` | MIT | | backend | production | Go | `github.com/go-redis/redis/v8` | `v8.11.5` | BSD-2-Clause |
| backend | production | Go | `github.com/mewkiz/flac` | `v1.0.13` | Unlicense | | backend | production | Go | `github.com/go-sql-driver/mysql` | `v1.7.0` | MPL-2.0 |
| backend | production | Go | `github.com/nicksnyder/go-i18n/v2` | `v2.6.1` | MIT | | backend | production | Go | `github.com/go-webauthn/webauthn` | `v0.14.0` | BSD-3-Clause |
| backend | test | Go | `github.com/openai/openai-go` | `v1.12.0` | Apache-2.0 | | backend | production | Go | `github.com/golang-jwt/jwt/v5` | `v5.3.0` | MIT |
| backend | production | Go | `github.com/pkg/errors` | `v0.9.1` | BSD-2-Clause | | backend | production | Go | `github.com/google/uuid` | `v1.6.0` | BSD-3-Clause |
| backend | production | Go | `github.com/pquerna/otp` | `v1.5.0` | Apache-2.0 | | backend | production | Go | `github.com/gorilla/websocket` | `v1.5.3` | BSD-2-Clause |
| backend | production | Go | `github.com/samber/hot` | `v0.11.0` | MIT | | backend | production | Go | `github.com/grafana/pyroscope-go` | `v1.2.7` | Apache-2.0 |
| backend | production | Go | `github.com/samber/lo` | `v1.52.0` | MIT | | backend | production | Go | `github.com/jackc/pgx/v5` | `v5.9.2` | MIT |
| backend | production | Go | `github.com/shirou/gopsutil` | `v3.21.11+incompatible` | BSD-3-Clause | | backend | production | Go | `github.com/jfreymuth/oggvorbis` | `v1.0.5` | MIT |
| backend | production | Go | `github.com/shopspring/decimal` | `v1.4.0` | MIT | | backend | production | Go | `github.com/jinzhu/copier` | `v0.4.0` | MIT |
| backend | production | Go | `github.com/stretchr/testify` | `v1.11.1` | MIT | | backend | production | Go | `github.com/joho/godotenv` | `v1.5.1` | MIT |
| backend | production | Go | `github.com/stripe/stripe-go/v81` | `v81.4.0` | MIT | | backend | test | Go | `github.com/json-iterator/go` | `v1.1.12` | MIT |
| backend | production | Go | `github.com/tcolgate/mp3` | `v0.0.0-20170426193717-e79c5a46d300` | MIT | | backend | production | Go | `github.com/klauspost/compress` | `v1.18.3` | BSD-3-Clause |
| backend | production | Go | `github.com/thanhpk/randstr` | `v1.0.6` | MIT | | backend | production | Go | `github.com/mewkiz/flac` | `v1.0.13` | Unlicense |
| backend | production | Go | `github.com/tidwall/gjson` | `v1.18.0` | MIT | | backend | production | Go | `github.com/nicksnyder/go-i18n/v2` | `v2.6.1` | MIT |
| backend | production | Go | `github.com/tidwall/sjson` | `v1.2.5` | MIT | | backend | test | Go | `github.com/openai/openai-go` | `v1.12.0` | Apache-2.0 |
| backend | production | Go | `github.com/tiktoken-go/tokenizer` | `v0.6.2` | MIT | | backend | production | Go | `github.com/pkg/errors` | `v0.9.1` | BSD-2-Clause |
| backend | production | Go | `github.com/waffo-com/waffo-go` | `v1.3.1` | MIT | | backend | production | Go | `github.com/pquerna/otp` | `v1.5.0` | Apache-2.0 |
| backend | production | Go | `github.com/yapingcat/gomedia` | `v0.0.0-20240906162731-17feea57090c` | MIT | | backend | production | Go | `github.com/prometheus/common` | `v0.62.0` | Apache-2.0 |
| backend | production | Go | `golang.org/x/crypto` | `v0.45.0` | BSD-3-Clause | | backend | production | Go | `github.com/samber/hot` | `v0.11.0` | MIT |
| backend | production | Go | `golang.org/x/image` | `v0.38.0` | BSD-3-Clause | | backend | production | Go | `github.com/samber/lo` | `v1.53.0` | MIT |
| backend | production | Go | `golang.org/x/net` | `v0.47.0` | BSD-3-Clause | | backend | production | Go | `github.com/shirou/gopsutil` | `v3.21.11+incompatible` | BSD-3-Clause |
| backend | production | Go | `golang.org/x/sync` | `v0.20.0` | BSD-3-Clause | | backend | production | Go | `github.com/shopspring/decimal` | `v1.4.0` | MIT |
| backend | production | Go | `golang.org/x/sys` | `v0.38.0` | BSD-3-Clause | | backend | test | Go | `github.com/stretchr/testify` | `v1.12.1` | MIT |
| backend | production | Go | `golang.org/x/text` | `v0.35.0` | BSD-3-Clause | | backend | production | Go | `github.com/stripe/stripe-go/v81` | `v81.4.0` | MIT |
| backend | production | Go | `gopkg.in/yaml.v3` | `v3.0.1` | Apache-2.0 OR MIT | | backend | production | Go | `github.com/tcolgate/mp3` | `v0.0.0-20170426193717-e79c5a46d300` | MIT |
| backend | production | Go | `gorm.io/driver/mysql` | `v1.4.3` | MIT | | backend | production | Go | `github.com/thanhpk/randstr` | `v1.0.6` | MIT |
| backend | production | Go | `gorm.io/driver/postgres` | `v1.5.2` | MIT | | backend | production | Go | `github.com/tidwall/gjson` | `v1.19.0` | MIT |
| backend | production | Go | `gorm.io/gorm` | `v1.25.2` | MIT | | backend | production | Go | `github.com/tidwall/sjson` | `v1.2.5` | MIT |
| backend | production | Go | `github.com/expr-lang/expr` | `v1.17.8` | MIT | | backend | production | Go | `github.com/tiktoken-go/tokenizer` | `v0.8.1` | MIT |
| web | production | npm | `@base-ui/react` | `1.6.0` | MIT | | backend | production | Go | `github.com/waffo-com/waffo-go` | `v1.3.2` | MIT |
| web | production | npm | `@codemirror/lang-javascript` | `6.2.5` | MIT | | backend | production | Go | `github.com/waffo-com/waffo-pancake-sdk-go` | `v0.3.1` | MIT |
| web | production | npm | `@codemirror/lang-markdown` | `6.5.1` | MIT | | backend | production | Go | `github.com/yapingcat/gomedia` | `v0.0.0-20240906162731-17feea57090c` | MIT |
| web | production | npm | `@codemirror/language` | `6.12.4` | MIT | | backend | production | Go | `golang.org/x/crypto` | `v0.52.0` | BSD-3-Clause |
| web | production | npm | `@codemirror/state` | `6.7.1` | MIT | | backend | production | Go | `golang.org/x/image` | `v0.41.0` | BSD-3-Clause |
| web | production | npm | `@codemirror/view` | `6.43.6` | MIT | | backend | production | Go | `golang.org/x/net` | `v0.55.0` | BSD-3-Clause |
| web | production | npm | `@fontsource-variable/lora` | `5.3.0` | OFL-1.1 | | backend | production | Go | `golang.org/x/oauth2` | `v0.36.0` | BSD-3-Clause |
| web | production | npm | `@fontsource-variable/public-sans` | `5.3.0` | OFL-1.1 | | backend | production | Go | `golang.org/x/sync` | `v0.20.0` | BSD-3-Clause |
| web | production | npm | `@hookform/resolvers` | `5.4.0` | MIT | | backend | production | Go | `golang.org/x/sys` | `v0.45.0` | BSD-3-Clause |
| web | production | npm | `@hugeicons/core-free-icons` | `4.2.2` | MIT | | backend | production | Go | `golang.org/x/text` | `v0.37.0` | BSD-3-Clause |
| web | production | npm | `@hugeicons/react` | `1.1.9` | MIT | | backend | production | Go | `gopkg.in/yaml.v3` | `v3.0.1` | Apache-2.0 OR MIT |
| web | production | npm | `@lezer/highlight` | `1.2.3` | MIT | | backend | production | Go | `gorm.io/driver/clickhouse` | `v0.6.0` | MIT |
| web | production | npm | `@lobehub/icons` | `5.14.0` | MIT | | backend | production | Go | `gorm.io/driver/mysql` | `v1.5.7` | MIT |
| web | production | npm | `@tanstack/react-query` | `5.101.2` | MIT | | backend | production | Go | `gorm.io/driver/postgres` | `v1.5.9` | MIT |
| web | production | npm | `@tanstack/react-router` | `1.170.18` | MIT | | backend | production | Go | `gorm.io/gorm` | `v1.25.12` | MIT |
| web | production | npm | `@tanstack/react-table` | `8.21.3` | MIT | | web | production | npm | `@base-ui/react` | `1.6.0` | MIT |
| web | production | npm | `@tanstack/react-virtual` | `3.14.6` | MIT | | web | production | npm | `@codemirror/lang-javascript` | `6.2.5` | MIT |
| web | production | npm | `@visactor/react-vchart` | `2.1.4` | MIT | | web | production | npm | `@codemirror/lang-markdown` | `6.5.1` | MIT |
| web | production | npm | `@visactor/vchart` | `2.1.4` | MIT | | web | production | npm | `@codemirror/language` | `6.12.4` | MIT |
| web | production | npm | `ai` | `7.0.31` | Apache-2.0 | | web | production | npm | `@codemirror/state` | `6.7.1` | MIT |
| web | production | npm | `auto-skeleton-react` | `1.0.5` | MIT | | web | production | npm | `@codemirror/view` | `6.43.6` | MIT |
| web | production | npm | `axios` | `1.18.1` | MIT | | web | production | npm | `@fontsource-variable/lora` | `5.3.0` | OFL-1.1 |
| web | production | npm | `class-variance-authority` | `0.7.1` | Apache-2.0 | | web | production | npm | `@fontsource-variable/public-sans` | `5.3.0` | OFL-1.1 |
| web | production | npm | `clsx` | `2.1.1` | MIT | | web | production | npm | `@hookform/resolvers` | `5.4.0` | MIT |
| web | production | npm | `cmdk` | `1.1.1` | MIT | | web | production | npm | `@hugeicons/core-free-icons` | `4.2.2` | MIT |
| web | production | npm | `dayjs` | `1.11.21` | MIT | | web | production | npm | `@hugeicons/react` | `1.1.9` | MIT |
| web | production | npm | `dompurify` | `3.4.11` | Apache-2.0 OR MPL-2.0 | | web | production | npm | `@lezer/highlight` | `1.2.3` | MIT |
| web | production | npm | `i18next` | `26.3.6` | MIT | | web | production | npm | `@lobehub/icons` | `5.22.0` | MIT |
| web | production | npm | `i18next-browser-languagedetector` | `8.2.1` | MIT | | web | production | npm | `@tanstack/react-query` | `5.101.2` | MIT |
| web | production | npm | `input-otp` | `1.4.2` | MIT | | web | production | npm | `@tanstack/react-router` | `1.170.18` | MIT |
| web | production | npm | `katex` | `0.17.0` | MIT | | web | production | npm | `@tanstack/react-table` | `8.21.3` | MIT |
| web | production | npm | `lucide-react` | `1.25.0` | ISC | | web | production | npm | `@tanstack/react-virtual` | `3.14.6` | MIT |
| web | production | npm | `marked` | `18.0.6` | MIT | | web | production | npm | `@visactor/react-vchart` | `2.1.4` | MIT |
| web | production | npm | `motion` | `12.42.2` | MIT | | web | production | npm | `@visactor/vchart` | `2.1.4` | MIT |
| web | production | npm | `nanoid` | `5.1.16` | MIT | | web | production | npm | `ai` | `7.0.136` | Apache-2.0 |
| web | production | npm | `next-themes` | `0.4.6` | MIT | | web | production | npm | `auto-skeleton-react` | `1.0.5` | MIT |
| web | production | npm | `qrcode.react` | `4.2.0` | ISC | | web | production | npm | `axios` | `1.18.1` | MIT |
| web | production | npm | `react` | `19.2.7` | MIT | | web | production | npm | `class-variance-authority` | `0.7.1` | Apache-2.0 |
| web | production | npm | `react-day-picker` | `10.0.1` | MIT | | web | production | npm | `clsx` | `2.1.1` | MIT |
| web | production | npm | `react-dom` | `19.2.7` | MIT | | web | production | npm | `cmdk` | `1.1.1` | MIT |
| web | production | npm | `react-hook-form` | `7.82.0` | MIT | | web | production | npm | `dayjs` | `1.11.21` | MIT |
| web | production | npm | `react-i18next` | `17.0.10` | MIT | | web | production | npm | `dompurify` | `3.4.16` | Apache-2.0 OR MPL-2.0 |
| web | production | npm | `react-icons` | `5.7.0` | MIT | | web | production | npm | `i18next` | `26.3.6` | MIT |
| web | production | npm | `react-resizable-panels` | `4.12.2` | MIT | | web | production | npm | `i18next-browser-languagedetector` | `8.2.1` | MIT |
| web | production | npm | `react-top-loading-bar` | `3.0.2` | MIT | | web | production | npm | `input-otp` | `1.4.2` | MIT |
| web | production | npm | `recharts` | `3.9.1` | MIT | | web | production | npm | `katex` | `0.18.2` | MIT |
| web | production | npm | `shiki` | `4.3.1` | MIT | | web | production | npm | `lucide-react` | `1.25.0` | ISC |
| web | production | npm | `sonner` | `2.0.7` | MIT | | web | production | npm | `marked` | `18.0.6` | MIT |
| web | production | npm | `sse.js` | `2.8.0` | Apache-2.0 | | web | production | npm | `motion` | `12.42.2` | MIT |
| web | production | npm | `stream-markdown-parser` | `1.1.3` | MIT | | web | production | npm | `nanoid` | `5.1.16` | MIT |
| web | production | npm | `tailwind-merge` | `3.6.0` | MIT | | web | production | npm | `next-themes` | `0.4.6` | MIT |
| web | production | npm | `tailwindcss` | `4.3.3` | MIT | | web | production | npm | `node-forge` | `1.4.0` | BSD-3-Clause OR GPL-2.0 |
| web | production | npm | `tokenlens` | `1.3.1` | MIT | | web | production | npm | `qrcode.react` | `4.2.0` | ISC |
| web | production | npm | `tw-animate-css` | `1.4.0` | MIT | | web | production | npm | `react` | `19.2.7` | MIT |
| web | production | npm | `use-stick-to-bottom` | `1.1.6` | MIT | | web | production | npm | `react-day-picker` | `10.0.1` | MIT |
| web | production | npm | `vaul` | `1.1.2` | MIT | | web | production | npm | `react-dom` | `19.2.7` | MIT |
| web | production | npm | `zod` | `4.4.3` | MIT | | web | production | npm | `react-hook-form` | `7.82.0` | MIT |
| web | production | npm | `zustand` | `5.0.14` | MIT | | web | production | npm | `react-i18next` | `17.0.10` | MIT |
| web | development | npm | `@rsbuild/core` | `2.1.6` | MIT | | web | production | npm | `react-icons` | `5.7.0` | MIT |
| web | development | npm | `@rsbuild/plugin-react` | `2.1.0` | MIT | | web | production | npm | `react-resizable-panels` | `4.12.2` | MIT |
| web | development | npm | `@rsbuild/plugin-tailwindcss` | `2.0.3` | MIT | | web | production | npm | `react-top-loading-bar` | `3.0.2` | MIT |
| web | development | npm | `@tanstack/react-query-devtools` | `5.101.2` | MIT | | web | production | npm | `recharts` | `3.9.1` | MIT |
| web | development | npm | `@tanstack/react-router-devtools` | `1.167.0` | MIT | | web | production | npm | `shiki` | `4.3.1` | MIT |
| web | development | npm | `@tanstack/router-plugin` | `1.168.23` | MIT | | web | production | npm | `sonner` | `2.0.7` | MIT |
| web | development | npm | `@types/node` | `26.1.1` | MIT | | web | production | npm | `sse.js` | `2.8.0` | Apache-2.0 |
| web | development | npm | `@types/react` | `19.2.17` | MIT | | web | production | npm | `stream-markdown-parser` | `1.2.18` | MIT |
| web | development | npm | `@types/react-dom` | `19.2.3` | MIT | | web | production | npm | `tailwind-merge` | `3.6.0` | MIT |
| web | development | npm | `@typescript/native-preview` | `7.0.0-dev.20260707.2` | Apache-2.0 | | web | production | npm | `tailwindcss` | `4.3.3` | MIT |
| web | development | npm | `@xyflow/react` | `12.11.2` | MIT | | web | production | npm | `tokenlens` | `1.3.1` | MIT |
| web | development | npm | `embla-carousel-react` | `8.6.0` | MIT | | web | production | npm | `tw-animate-css` | `1.4.0` | MIT |
| web | development | npm | `knip` | `6.27.0` | ISC | | web | production | npm | `use-stick-to-bottom` | `1.1.6` | MIT |
| web | development | npm | `oxfmt` | `0.57.0` | MIT | | web | production | npm | `vaul` | `1.1.2` | MIT |
| web | development | npm | `oxlint` | `1.74.0` | MIT | | web | production | npm | `yace` | `1.1.1` | MIT |
| web | development | npm | `shadcn` | `4.13.1` | MIT | | web | production | npm | `yaml` | `2.9.0` | ISC |
| electron | development | npm | `cross-env` | `7.0.3` | MIT | | web | production | npm | `zod` | `4.4.3` | MIT |
| electron | development | npm | `electron` | `39.8.5` | MIT | | web | production | npm | `zustand` | `5.0.14` | MIT |
| electron | development | npm | `electron-builder` | `26.7.0` | MIT | | web | development | npm | `@rsbuild/core` | `2.1.6` | MIT |
| web | development | npm | `@rsbuild/plugin-react` | `2.1.0` | MIT |
| web | development | npm | `@rsbuild/plugin-tailwindcss` | `2.0.3` | MIT |
| web | development | npm | `@tanstack/react-query-devtools` | `5.101.2` | MIT |
| web | development | npm | `@tanstack/react-router-devtools` | `1.167.0` | MIT |
| web | development | npm | `@tanstack/router-plugin` | `1.168.42` | MIT |
| web | development | npm | `@testing-library/jest-dom` | `7.0.0` | MIT |
| web | development | npm | `@testing-library/react` | `16.3.2` | MIT |
| web | development | npm | `@testing-library/user-event` | `14.6.1` | MIT |
| web | development | npm | `@types/node` | `26.1.1` | MIT |
| 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 |
| web | development | npm | `knip` | `6.27.0` | ISC |
| web | development | npm | `oxfmt` | `0.72.0` | MIT |
| web | development | npm | `oxlint` | `1.74.0` | MIT |
| web | development | npm | `shadcn` | `4.13.1` | MIT |
| web | development | npm | `vitest` | `4.1.10` | MIT |
| electron | development | npm | `cross-env` | `7.0.3` | MIT |
| electron | development | npm | `electron` | `41.10.6` | MIT |
| electron | development | npm | `electron-builder` | `26.15.3` | MIT |
## License Texts ## License Texts
@@ -297,6 +321,13 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
### MPL-2.0
Mozilla Public License 2.0
https://mozilla.org/MPL/2.0/
The source code of the MPL-covered files is available from the upstream project at https://github.com/go-sql-driver/mysql.
### OFL-1.1 ### OFL-1.1
SIL Open Font License 1.1 SIL Open Font License 1.1
@@ -308,10 +339,6 @@ https://openfontlicense.org/open-font-license-official-text/
When distributing font files, preserve the OFL license text, copyright notices, When distributing font files, preserve the OFL license text, copyright notices,
and reserved font name restrictions supplied by the upstream font project. and reserved font name restrictions supplied by the upstream font project.
### Proprietary/Internal - owned by project maintainer
This dependency is owned by the project maintainer and is not treated as a third-party open source dependency for this review.
### Unlicense ### Unlicense
The Unlicense The Unlicense
+1 -1
View File
@@ -1 +1 @@
v1.0.0-rc.38-custom.67 v1.0.0-rc.45-custom.97
Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

-6
View File
@@ -1,6 +0,0 @@
UPDATE users
SET quota = quota + (
SELECT SUM(remain_quota)
FROM tokens
WHERE tokens.user_id = users.id
)
-17
View File
@@ -1,17 +0,0 @@
INSERT INTO abilities (`group`, model, channel_id, enabled)
SELECT c.`group`, m.model, c.id, 1
FROM channels c
CROSS JOIN (
SELECT 'gpt-3.5-turbo' AS model UNION ALL
SELECT 'gpt-3.5-turbo-0301' AS model UNION ALL
SELECT 'gpt-4' AS model UNION ALL
SELECT 'gpt-4-0314' AS model
) AS m
WHERE c.status = 1
AND NOT EXISTS (
SELECT 1
FROM abilities a
WHERE a.`group` = c.`group`
AND a.model = m.model
AND a.channel_id = c.id
);
-40
View File
@@ -1,40 +0,0 @@
#!/bin/bash
if [ $# -lt 3 ]; then
echo "Usage: time_test.sh <domain> <key> <count> [<model>]"
exit 1
fi
domain=$1
key=$2
count=$3
model=${4:-"gpt-3.5-turbo"} # 设置默认模型为 gpt-3.5-turbo
total_time=0
times=()
for ((i=1; i<=count; i++)); do
result=$(curl -o /dev/null -s -w "%{http_code} %{time_total}\\n" \
https://"$domain"/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $key" \
-d '{"messages": [{"content": "echo hi", "role": "user"}], "model": "'"$model"'", "stream": false, "max_tokens": 1}')
http_code=$(echo "$result" | awk '{print $1}')
time=$(echo "$result" | awk '{print $2}')
echo "HTTP status code: $http_code, Time taken: $time"
total_time=$(bc <<< "$total_time + $time")
times+=("$time")
done
average_time=$(echo "scale=4; $total_time / $count" | bc)
sum_of_squares=0
for time in "${times[@]}"; do
difference=$(echo "scale=4; $time - $average_time" | bc)
square=$(echo "scale=4; $difference * $difference" | bc)
sum_of_squares=$(echo "scale=4; $sum_of_squares + $square" | bc)
done
standard_deviation=$(echo "scale=4; sqrt($sum_of_squares / $count)" | bc)
echo "Average time: $average_time±$standard_deviation"
-9
View File
@@ -1,9 +0,0 @@
set -eu
go test ./pkg/perf_metrics ./model ./controller ./service ./relay/helper -run 'TestGroupBuckets|TestRequestStatus|TestGroupModelPricing|TestGetEnabledGroupSetRequiresEnabledAbilityAndChannel|TestSelectableGroupsRequireEnabledChannel|TestAddTokenPersistsFixedBackupGroup|TestUpdateTokenAutoGroupsTriStateAndFixedBackup|TestAddTokenRejectsInvalidFixedBackupGroup|TestCacheGetRandomSatisfiedChannelUsesTokenAutoGroupsWhenGlobalAutoIsEmpty|TestCacheGetRandomSatisfiedChannelUsesFixedTokenBackupGroup|TestCacheGetRandomSatisfiedChannelSkipsUnavailableFixedPrimary|TestAppendStreamStatusTreatsClientGoneAsDisplayNormal|TestClientFacingOpenAIErrorHidesUpstreamDetails|TestClientFacingClaudeErrorHidesUpstreamDetails|TestClientFacingErrorKeepsLocalValidationMessage|TestPublicUpstreamErrorContent' -count=1
go test ./model ./controller ./service -run 'TestChannelGetSettingDefaultsAstraGuardOn|TestFixedBackup|TestShouldSkipRetryAfterChannelAffinityFailure|TestShouldRetryHonorsPinRetryMode|TestGroupPool|TestBuildGroupPoolTiers|TestNextTierIndex|TestGroupAvailability|TestGetGroupPool|TestSetGroupPool' -count=1
go test ./model ./service ./controller ./relay/helper -run 'TestRequestDetails|TestSelectionTrace|TestResponsesInterruptedStreamHealth|TestResponsesWebSocketReusesConnectionAndSettlesEachRequest|TestStreamScanner' -count=1
(cd relaykit && GOWORK=off go build ./...)
go test ./relay -run 'TestResponseGuard|TestResponseModel' -count=1
go test ./controller ./middleware ./service -run 'TestTokenAstraMigration|TestRelayAttemptGuardResult|TestRelayAttemptSwitchSafety|TestTokenAuth|TestAstra' -count=1
go test ./pkg/model_fingerprint ./controller ./service ./router -run 'TestFingerprint|TestAstraSampling|TestSystemTask|TestChannelPermission' -count=1
go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
+2 -2
View File
@@ -20,7 +20,7 @@ import (
// GetAudioDuration 使用纯 Go 库获取音频文件的时长(秒)。 // GetAudioDuration 使用纯 Go 库获取音频文件的时长(秒)。
// 它不再依赖外部的 ffmpeg 或 ffprobe 程序。 // 它不再依赖外部的 ffmpeg 或 ffprobe 程序。
func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) { func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) {
SysLog(fmt.Sprintf("GetAudioDuration: ext=%s", ext)) SysLog(LogText("GetAudioDuration: ext=%s", ext))
ext = strings.ToLower(ext) ext = strings.ToLower(ext)
// 根据文件扩展名选择解析器 // 根据文件扩展名选择解析器
switch ext { switch ext {
@@ -46,7 +46,7 @@ func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duratio
default: default:
return 0, fmt.Errorf("unsupported audio format: %s", ext) return 0, fmt.Errorf("unsupported audio format: %s", ext)
} }
SysLog(fmt.Sprintf("GetAudioDuration: duration=%f", duration)) SysLog(LogText("GetAudioDuration: duration=%f", duration))
return duration, err return duration, err
} }
+1 -1
View File
@@ -294,7 +294,7 @@ func CreateBodyStorage(data []byte) (BodyStorage, error) {
storage, err := newDiskStorage(data, GetDiskCachePath()) storage, err := newDiskStorage(data, GetDiskCachePath())
if err != nil { if err != nil {
// 如果磁盘存储失败,回退到内存存储 // 如果磁盘存储失败,回退到内存存储
SysError(fmt.Sprintf("failed to create disk storage, falling back to memory: %v", err)) SysError(LogText("failed to create disk storage, falling back to memory: %v", err))
return newMemoryStorage(data), nil return newMemoryStorage(data), nil
} }
return storage, nil return storage, nil
+13
View File
@@ -35,6 +35,11 @@ var DefaultCollapseSidebar = false // default value of collapse sidebar
var SessionSecret = uuid.New().String() var SessionSecret = uuid.New().String()
var CryptoSecret = uuid.New().String() var CryptoSecret = uuid.New().String()
var SessionCookieSecure = false var SessionCookieSecure = false
// DefaultLanguage is the DEFAULT_LANGUAGE environment variable: the language of
// server log lines and of text for a reader who states no language: en, zh-CN
// or zh-TW. English when empty.
var DefaultLanguage string
var SessionCookieTrustedURLs []string var SessionCookieTrustedURLs []string
const ( const (
@@ -128,7 +133,11 @@ var QuotaForInvitee = 0
var ChannelDisableThreshold = 5.0 var ChannelDisableThreshold = 5.0
var AutomaticDisableChannelEnabled = false var AutomaticDisableChannelEnabled = false
var AutomaticEnableChannelEnabled = false var AutomaticEnableChannelEnabled = false
var QuotaRemindEnabled = true
var QuotaRemindThreshold = 1000 var QuotaRemindThreshold = 1000
// PreConsumedQuota is retained for old option clients; token reservations now
// use quota_setting.pre_consume_multiplier and the estimated input cost.
var PreConsumedQuota = 500 var PreConsumedQuota = 500
var RetryTimes = 0 var RetryTimes = 0
@@ -215,6 +224,10 @@ var (
GlobalWebRateLimitNum int GlobalWebRateLimitNum int
GlobalWebRateLimitDuration int64 GlobalWebRateLimitDuration int64
GlobalStaticRateLimitEnable bool
GlobalStaticRateLimitNum int
GlobalStaticRateLimitDuration int64
CriticalRateLimitEnable bool CriticalRateLimitEnable bool
CriticalRateLimitNum = 20 CriticalRateLimitNum = 20
CriticalRateLimitDuration int64 = 20 * 60 CriticalRateLimitDuration int64 = 20 * 60
+174
View File
@@ -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
}
+2 -2
View File
@@ -84,7 +84,7 @@ func SendEmail(subject string, receiver string, content string) error {
return err2 return err2
} }
if SMTPServer == "" && SMTPAccount == "" { if SMTPServer == "" && SMTPAccount == "" {
return fmt.Errorf("SMTP 服务器未配置") return NewMessage("SMTP server is not configured")
} }
encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject))) encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
mail := []byte(fmt.Sprintf("To: %s\r\n"+ mail := []byte(fmt.Sprintf("To: %s\r\n"+
@@ -130,7 +130,7 @@ func SendEmail(subject string, receiver string, content string) error {
} }
err = client.Quit() err = client.Quit()
if err != nil { if err != nil {
SysError(fmt.Sprintf("failed to send email to %s: %v", receiver, err)) SysError(LogText("failed to send email to %s: %v", receiver, err))
} }
return err return err
} }
+2
View File
@@ -21,6 +21,8 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"}, constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
constant.EndpointTypeOpenAIResponseCompact: {Path: "/v1/responses/compact", Method: "POST"}, constant.EndpointTypeOpenAIResponseCompact: {Path: "/v1/responses/compact", Method: "POST"},
constant.EndpointTypeOpenAIAlphaSearch: {Path: "/v1/alpha/search", 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.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"}, constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
constant.EndpointTypeJinaRerank: {Path: "/v1/rerank", Method: "POST"}, constant.EndpointTypeJinaRerank: {Path: "/v1/rerank", Method: "POST"},
+7
View File
@@ -4,6 +4,13 @@ import "github.com/QuantumNous/new-api/constant"
// GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点) // GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点)
func GetEndpointTypesByChannelType(channelType int, modelName string) []constant.EndpointType { 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 var endpointTypes []constant.EndpointType
switch channelType { switch channelType {
case constant.ChannelTypeJina: case constant.ChannelTypeJina:
+2 -3
View File
@@ -1,7 +1,6 @@
package common package common
import ( import (
"fmt"
"os" "os"
"strconv" "strconv"
) )
@@ -12,7 +11,7 @@ func GetEnvOrDefault(env string, defaultValue int) int {
} }
num, err := strconv.Atoi(os.Getenv(env)) num, err := strconv.Atoi(os.Getenv(env))
if err != nil { if err != nil {
SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %d", env, err.Error(), defaultValue)) SysError(LogText("failed to parse %s: %s, using default value: %d", env, err.Error(), defaultValue))
return defaultValue return defaultValue
} }
return num return num
@@ -31,7 +30,7 @@ func GetEnvOrDefaultBool(env string, defaultValue bool) bool {
} }
b, err := strconv.ParseBool(os.Getenv(env)) b, err := strconv.ParseBool(os.Getenv(env))
if err != nil { if err != nil {
SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %t", env, err.Error(), defaultValue)) SysError(LogText("failed to parse %s: %s, using default value: %t", env, err.Error(), defaultValue))
return defaultValue return defaultValue
} }
return b return b
+105 -3
View File
@@ -4,21 +4,26 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"maps"
"mime" "mime"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"sync"
"time" "time"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/net/http/httpproxy"
) )
const KeyRequestBody = "key_request_body" const KeyRequestBody = "key_request_body"
const KeyBodyStorage = "key_body_storage" const KeyBodyStorage = "key_body_storage"
const keyBodyReadError = "key_body_read_error"
var ErrRequestBodyTooLarge = errors.New("request body too large") var ErrRequestBodyTooLarge = errors.New("request body too large")
@@ -57,6 +62,12 @@ func GetRequestBody(c *gin.Context) (io.Seeker, error) {
} }
} }
// A failed read has consumed and closed the body. Later readers get the
// same error instead of "http: invalid Read on closed Body".
if readErr, failed := c.Get(keyBodyReadError); failed {
return nil, readErr.(error)
}
maxMB := constant.MaxRequestBodyMB maxMB := constant.MaxRequestBodyMB
if maxMB <= 0 { if maxMB <= 0 {
maxMB = 128 // 默认 128MB maxMB = 128 // 默认 128MB
@@ -71,8 +82,9 @@ func GetRequestBody(c *gin.Context) (io.Seeker, error) {
if err != nil { if err != nil {
if IsRequestBodyTooLargeError(err) { if IsRequestBodyTooLargeError(err) {
return nil, errors.Wrap(ErrRequestBodyTooLarge, fmt.Sprintf("request body exceeds %d MB", maxMB)) err = errors.Wrap(ErrRequestBodyTooLarge, fmt.Sprintf("request body exceeds %d MB", maxMB))
} }
c.Set(keyBodyReadError, err)
return nil, err return nil, err
} }
@@ -166,6 +178,62 @@ func GetContextKeyString(c *gin.Context, key constant.ContextKey) string {
return c.GetString(string(key)) return c.GetString(string(key))
} }
// presetUpstreamHosts are the hosts, with any port, of the default base URLs
// new-api presets for channel types. They are public endpoints, so errors keep
// them readable. The port keeps a configured localhost upstream apart from the
// Ollama preset.
var presetUpstreamHosts = sync.OnceValue(func() map[string]struct{} {
hosts := make(map[string]struct{}, len(constant.ChannelBaseURLs))
for _, baseURL := range constant.ChannelBaseURLs {
if parsed, err := url.Parse(baseURL); err == nil && parsed.Host != "" {
hosts[strings.ToLower(parsed.Host)] = struct{}{}
}
}
return hosts
})
// isPresetUpstreamHost reports whether address points at one of the preset
// default base URL hosts.
func isPresetUpstreamHost(address string) bool {
rawURL := strings.TrimSpace(address)
if !strings.Contains(rawURL, "://") {
rawURL = "//" + rawURL
}
parsed, err := url.Parse(rawURL)
if err != nil {
return false
}
_, preset := presetUpstreamHosts()[strings.ToLower(parsed.Host)]
return preset
}
// UpstreamAddresses returns the addresses masked in errors shown to users: the
// upstream the admin configured for the selected channel and the proxies its
// requests may go through. Preset default base URLs are public and stay
// readable. The origin actually requested only counts for a configured base
// URL or an Advanced Custom channel, whose routes may target absolute URLs;
// other adaptors build official endpoints themselves.
func UpstreamAddresses(c *gin.Context) []string {
// A channel without its own proxy dials through the environment proxy.
environmentProxy := httpproxy.FromEnvironment()
candidates := []string{environmentProxy.HTTPProxy, environmentProxy.HTTPSProxy}
if setting, ok := GetContextKeyType[dto.ChannelSettings](c, constant.ContextKeyChannelSetting); ok {
candidates = append(candidates, setting.Proxy)
}
baseURL := strings.TrimSpace(GetContextKeyString(c, constant.ContextKeyChannelBaseUrl))
candidates = append(candidates, baseURL)
if GetContextKeyInt(c, constant.ContextKeyChannelType) == constant.ChannelTypeAdvancedCustom || (baseURL != "" && !isPresetUpstreamHost(baseURL)) {
candidates = append(candidates, GetContextKeyString(c, constant.ContextKeyUpstreamRequestOrigin))
}
addresses := make([]string, 0, len(candidates))
for _, address := range candidates {
if strings.TrimSpace(address) != "" && !isPresetUpstreamHost(address) {
addresses = append(addresses, address)
}
}
return addresses
}
func GetContextKeyInt(c *gin.Context, key constant.ContextKey) int { func GetContextKeyInt(c *gin.Context, key constant.ContextKey) int {
return c.GetInt(string(key)) return c.GetInt(string(key))
} }
@@ -196,11 +264,45 @@ func GetContextKeyType[T any](c *gin.Context, key constant.ContextKey) (T, bool)
return t, false return t, false
} }
// ApiError responds with err's text. When err is or wraps a *Message, the
// response also carries message_key and message_params for the web console.
func ApiError(c *gin.Context, err error) { func ApiError(c *gin.Context, err error) {
c.JSON(http.StatusOK, gin.H{ ApiErrorStatus(c, http.StatusOK, err)
}
// ApiErrorStatus is ApiError with another HTTP status. fields adds response
// fields such as a stable error code or data.
func ApiErrorStatus(c *gin.Context, status int, err error, fields ...gin.H) {
body := gin.H{
"success": false, "success": false,
"message": err.Error(), "message": err.Error(),
}) }
var message *Message
if errors.As(err, &message) {
maps.Copy(body, message.Fields())
}
for _, extra := range fields {
maps.Copy(body, extra)
}
c.JSON(status, body)
}
// ApiErrorT responds with a web console message; key is the English source
// text, which the web console translates with params.
func ApiErrorT(c *gin.Context, key string, params ...map[string]any) {
ApiError(c, NewMessage(key, params...))
}
// ApiSuccessT is the success counterpart of ApiErrorT.
func ApiSuccessT(c *gin.Context, key string, data any, params ...map[string]any) {
message := NewMessage(key, params...)
body := gin.H{
"success": true,
"message": message.Error(),
"data": data,
}
maps.Copy(body, message.Fields())
c.JSON(http.StatusOK, body)
} }
func ApiErrorMsg(c *gin.Context, msg string) { func ApiErrorMsg(c *gin.Context, msg string) {
+1 -2
View File
@@ -2,7 +2,6 @@ package common
import ( import (
"context" "context"
"fmt"
"math" "math"
"github.com/bytedance/gopkg/util/gopool" "github.com/bytedance/gopkg/util/gopool"
@@ -16,7 +15,7 @@ func init() {
if stopChan, ok := ctx.Value("stop_chan").(chan bool); ok { if stopChan, ok := ctx.Value("stop_chan").(chan bool); ok {
SafeSendBool(stopChan, true) SafeSendBool(stopChan, true)
} }
SysError(fmt.Sprintf("panic in gopool.RelayPool: %v", i)) SysError(LogText("panic in gopool.RelayPool: %v", i))
}) })
} }
+13 -7
View File
@@ -47,12 +47,14 @@ func InitEnv() {
os.Exit(0) os.Exit(0)
} }
DefaultLanguage = os.Getenv("DEFAULT_LANGUAGE")
SetLogLanguage(DefaultLanguage)
if os.Getenv("SESSION_SECRET") != "" { if os.Getenv("SESSION_SECRET") != "" {
ss := os.Getenv("SESSION_SECRET") ss := os.Getenv("SESSION_SECRET")
if ss == "random_string" { if ss == "random_string" {
log.Println("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string.") log.Println(LogText("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string."))
log.Println("警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。") log.Fatal(LogText("Please set SESSION_SECRET to a random string."))
log.Fatal("Please set SESSION_SECRET to a random string.")
} else { } else {
SessionSecret = ss SessionSecret = ss
} }
@@ -95,7 +97,7 @@ func InitEnv() {
if tr.TLSClientConfig != nil { if tr.TLSClientConfig != nil {
tr.TLSClientConfig.InsecureSkipVerify = true tr.TLSClientConfig.InsecureSkipVerify = true
} else { } else {
tr.TLSClientConfig = InsecureTLSConfig tr.TLSClientConfig = InsecureTLSConfig.Clone()
} }
} }
} }
@@ -128,6 +130,10 @@ func InitEnv() {
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 120) GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 120)
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180)) GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
GlobalStaticRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_STATIC_RATE_LIMIT_ENABLE", false)
GlobalStaticRateLimitNum = GetEnvOrDefault("GLOBAL_STATIC_RATE_LIMIT", 1000)
GlobalStaticRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_STATIC_RATE_LIMIT_DURATION", 180))
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true) CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20) CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20)
CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60)) CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60))
@@ -147,7 +153,7 @@ func initUserSessionSettings() {
const secondsPerDay = 24 * 60 * 60 const secondsPerDay = 24 * 60 * 60
if int64(UserSessionRevokedRetentionDays) > math.MaxInt64/secondsPerDay { if int64(UserSessionRevokedRetentionDays) > math.MaxInt64/secondsPerDay {
SysError(fmt.Sprintf( SysError(LogText(
"USER_SESSION_REVOKED_RETENTION_DAYS is too large, using default value: %d", "USER_SESSION_REVOKED_RETENTION_DAYS is too large, using default value: %d",
DefaultUserSessionRevokedRetentionDays, DefaultUserSessionRevokedRetentionDays,
)) ))
@@ -157,7 +163,7 @@ func initUserSessionSettings() {
if UserSessionIssuanceWindowSeconds > retentionSeconds { if UserSessionIssuanceWindowSeconds > retentionSeconds {
configuredWindow := UserSessionIssuanceWindowSeconds configuredWindow := UserSessionIssuanceWindowSeconds
UserSessionIssuanceWindowSeconds = retentionSeconds UserSessionIssuanceWindowSeconds = retentionSeconds
SysError(fmt.Sprintf( SysError(LogText(
"USER_SESSION_ISSUANCE_WINDOW_SECONDS exceeds revoked retention; configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d", "USER_SESSION_ISSUANCE_WINDOW_SECONDS exceeds revoked retention; configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d",
configuredWindow, configuredWindow,
retentionSeconds, retentionSeconds,
@@ -169,7 +175,7 @@ func initUserSessionSettings() {
func positiveUserSessionEnv(name string, fallback int) int { func positiveUserSessionEnv(name string, fallback int) int {
value := GetEnvOrDefault(name, fallback) value := GetEnvOrDefault(name, fallback)
if value <= 0 { if value <= 0 {
SysError(fmt.Sprintf("%s must be positive, using default value: %d", name, fallback)) SysError(LogText("%s must be positive, using default value: %d", name, fallback))
return fallback return fallback
} }
return value return value
+1 -1
View File
@@ -28,7 +28,7 @@ func New(ctx context.Context, r *redis.Client) *RedisLimiter {
// 预加载脚本 // 预加载脚本
limitSHA, err := r.ScriptLoad(ctx, rateLimitScript).Result() limitSHA, err := r.ScriptLoad(ctx, rateLimitScript).Result()
if err != nil { if err != nil {
common.SysLog(fmt.Sprintf("Failed to load rate limit script: %v", err)) common.SysLog(common.LogText("Failed to load rate limit script: %v", err))
} }
instance = &RedisLimiter{ instance = &RedisLimiter{
client: r, client: r,
+35
View File
@@ -0,0 +1,35 @@
package common
import (
_ "embed"
"fmt"
"strings"
)
// Server log lines are English. logTextZhCN holds the Chinese text of the
// lines that have one, keyed by their English format.
//
//go:embed log_text.zh-CN.json
var logTextZhCN []byte
// logTexts is the translation table DEFAULT_LANGUAGE selected; nil prints English.
var logTexts map[string]string
// SetLogLanguage selects the language of server log lines. A language that
// starts with "zh" prints the Chinese text; anything else prints English.
func SetLogLanguage(language string) {
logTexts = nil
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(language)), "zh") {
return
}
_ = Unmarshal(logTextZhCN, &logTexts)
}
// LogText formats a server log line in the language of DEFAULT_LANGUAGE. format is
// the English text and the key of its translation in log_text.zh-CN.json.
func LogText(format string, args ...any) string {
if translated, ok := logTexts[format]; ok {
return fmt.Sprintf(translated, args...)
}
return fmt.Sprintf(format, args...)
}
+854
View File
@@ -0,0 +1,854 @@
{
"%d quota points": "%d 点额度",
"%s after refresh: %s": "%s(刷新凭证后重试): %s",
"%s failed: %s": "%s 构建失败,%s",
"%s must be positive, using default value: %d": "%s 必须大于 0,已使用默认值: %d",
"%s must be positive; using default": "%s 必须大于 0,已使用默认值",
"%s%.6f quota": "%s%.6f 额度",
"--form 'file=@\"%s\"' (size: %d bytes, content-type: %s)": "--form 'file=@\"%s\"' (大小: %d 字节, content-type: %s)",
"Add abilities for channel %d failed: %s": "修复渠道一致性: 为渠道 %d 重建 abilities 记录失败: %s",
"Auto selected group: %s": "Auto 分组选定: %s",
"Auto selecting group: %s, priorityRetry: %d": "Auto 分组尝试: %s, priorityRetry: %d",
"Batch task %s already transitioned by another process, skip billing": "批量轮询: 任务 %s 的状态已由其他进程更新,跳过计费",
"Batch task %s reached terminal status without data; preserving existing task data": "批量轮询: 任务 %s 已结束,但上游没有返回数据,保留已有的任务数据",
"Batch task response ignored: unknown task_id=%s": "批量轮询: 上游返回了未知的 task_id=%s,已忽略",
"CacheUpdateChannel after: id=%d, name=%s, status=%d, polling_index=%d": "CacheUpdateChannel 更新后: id=%d, name=%s, status=%d, polling_index=%d",
"CacheUpdateChannel before: id=%d, name=%s, status=%d, polling_index=%d": "CacheUpdateChannel 更新前: id=%d, name=%s, status=%d, polling_index=%d",
"Channel #%d failed to update video async tasks: %s": "渠道 #%d 更新视频异步任务失败: %s",
"Channel #%d pending video tasks: %d": "渠道 #%d 有 %d 个视频任务未完成",
"CheckUserExistOrDeleted error: %v": "CheckUserExistOrDeleted 出错: %v",
"Client IP %s passed the token IP restrictions check": "客户端 IP %s 在令牌的 IP 白名单内",
"Creem API response received trade_no=%s status_code=%d body=%q": "Creem API 响应已收到 trade_no=%s status_code=%d body=%q",
"Creem callback customer email is empty trade_no=%s creem_order_id=%s": "Creem 回调客户邮箱为空 trade_no=%s creem_order_id=%s",
"Creem callback customer name is empty trade_no=%s creem_order_id=%s": "Creem 回调客户姓名为空 trade_no=%s creem_order_id=%s",
"Creem failed to create payment link user_id=%d trade_no=%s product_id=%s error=%q": "Creem 创建支付链接失败 user_id=%d trade_no=%s product_id=%s error=%q",
"Creem failed to create subscription payment link trade_no=%s product_id=%s error=%q": "Creem 订阅支付链接创建失败 trade_no=%s product_id=%s error=%q",
"Creem failed to create top-up order user_id=%d trade_no=%s product_id=%s error=%q": "Creem 创建充值订单失败 user_id=%d trade_no=%s product_id=%s error=%q",
"Creem failed to parse product config user_id=%d error=%q": "Creem 产品配置解析失败 user_id=%d error=%q",
"Creem failed to read payment request error=%q": "Creem 支付请求读取失败 error=%q",
"Creem failed to read subscription payment request error=%q": "Creem 订阅支付请求读取失败 error=%q",
"Creem order not paid, ignored request_id=%s order_id=%s order_status=%s": "Creem 订单状态未支付,忽略处理 request_id=%s order_id=%s order_status=%s",
"Creem order type not supported, ignored request_id=%s creem_order_id=%s order_type=%s": "Creem 暂不支持该订单类型,忽略处理 request_id=%s creem_order_id=%s order_type=%s",
"Creem payment completed callback trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q": "Creem 支付完成回调 trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q",
"Creem payment link created trade_no=%s response_id=%s checkout_url=%q": "Creem 支付链接创建成功 trade_no=%s response_id=%s checkout_url=%q",
"Creem payment request received user_id=%d body=%q": "Creem 支付请求已收到 user_id=%d body=%q",
"Creem payment request sent api_url=%s product_id=%s email=%q trade_no=%s": "Creem 支付请求已发送 api_url=%s product_id=%s email=%q trade_no=%s",
"Creem subscription order processed trade_no=%s creem_order_id=%s": "Creem 订阅订单处理成功 trade_no=%s creem_order_id=%s",
"Creem subscription order processing failed trade_no=%s creem_order_id=%s error=%q": "Creem 订阅订单处理失败 trade_no=%s creem_order_id=%s error=%q",
"Creem top-up order created user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f": "Creem 充值订单创建成功 user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f",
"Creem top-up order not found trade_no=%s creem_order_id=%s": "Creem 充值订单不存在 trade_no=%s creem_order_id=%s",
"Creem top-up order not pending, ignored trade_no=%s status=%s creem_order_id=%s": "Creem 充值订单状态非 pending,忽略处理 trade_no=%s status=%s creem_order_id=%s",
"Creem top-up processing failed trade_no=%s creem_order_id=%s client_ip=%s error=%q": "Creem 充值处理失败 trade_no=%s creem_order_id=%s client_ip=%s error=%q",
"Creem top-up succeeded trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s": "Creem 充值成功 trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s",
"Creem using test environment api_url=%s": "Creem 使用测试环境 api_url=%s",
"Creem webhook failed to parse path=%q client_ip=%s error=%q body=%q": "Creem webhook 解析失败 path=%q client_ip=%s error=%q body=%q",
"Creem webhook failed to read request body path=%q client_ip=%s error=%q": "Creem webhook 读取请求体失败 path=%q client_ip=%s error=%q",
"Creem webhook ignored event event_type=%s event_id=%s": "Creem webhook 忽略事件 event_type=%s event_id=%s",
"Creem webhook missing request_id event_id=%s order_id=%s": "Creem webhook 缺少 request_id event_id=%s order_id=%s",
"Creem webhook missing signature path=%q client_ip=%s body=%q": "Creem webhook 缺少签名 path=%q client_ip=%s body=%q",
"Creem webhook parsed event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s": "Creem webhook 解析成功 event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s",
"Creem webhook received request path=%q client_ip=%s signature=%q body=%q": "Creem webhook 收到请求 path=%q client_ip=%s signature=%q body=%q",
"Creem webhook rejected reason=webhook_disabled path=%q client_ip=%s": "Creem webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
"Creem webhook secret is not configured test_mode=%t signature=%q body=%q": "Creem webhook secret 未配置 test_mode=%t signature=%q body=%q",
"Creem webhook signature check skipped reason=test_mode signature=%q body=%q": "Creem webhook 验签已跳过 reason=test_mode signature=%q body=%q",
"Creem webhook signature verification failed path=%q client_ip=%s signature=%q body=%q": "Creem webhook 验签失败 path=%q client_ip=%s signature=%q body=%q",
"Creem webhook signature verified path=%q client_ip=%s": "Creem webhook 验签成功 path=%q client_ip=%s",
"Current group %s retries exhausted (priorityRetry=%d >= RetryTimes=%d), preparing switch to next group for next retry": "当前分组 %s 重试次数已用完 (priorityRetry=%d >= RetryTimes=%d),下次重试将切换到下一个分组",
"Database pinged successfully": "数据库连接正常",
"Delete abilities failed: %s": "修复渠道一致性: 删除 abilities 记录失败: %s",
"Do gemini request failed: %s": "请求 Gemini 失败: %s",
"Epay callback order not found trade_no=%s callback_type=%s client_ip=%s verify_info=%q": "易支付 回调订单不存在 trade_no=%s callback_type=%s client_ip=%s verify_info=%q",
"Epay client is not initialized path=%q client_ip=%s": "易支付 client 未初始化 path=%q client_ip=%s",
"Epay duplicate callback ignored trade_no=%s callback_type=%s client_ip=%s": "易支付 重复回调幂等忽略 trade_no=%s callback_type=%s client_ip=%s",
"Epay failed to create top-up order user_id=%d trade_no=%s payment_method=%s amount=%d error=%q": "易支付 创建充值订单失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q",
"Epay failed to start payment user_id=%d trade_no=%s payment_method=%s amount=%d error=%q": "易支付 拉起支付失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q",
"Epay order payment gateway mismatch trade_no=%s callback_type=%s client_ip=%s": "易支付 订单支付网关不匹配 trade_no=%s callback_type=%s client_ip=%s",
"Epay order status invalid trade_no=%s callback_type=%s client_ip=%s": "易支付 订单状态非法 trade_no=%s callback_type=%s client_ip=%s",
"Epay top-up order created user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q": "易支付 充值订单创建成功 user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q",
"Epay top-up processing failed trade_no=%s client_ip=%s error=%q": "易支付 充值处理失败 trade_no=%s client_ip=%s error=%q",
"Epay top-up succeeded trade_no=%s callback_type=%s client_ip=%s": "易支付 充值成功 trade_no=%s callback_type=%s client_ip=%s",
"Epay top-up succeeded trade_no=%s user_id=%d quota_to_add=%d money=%.2f": "易支付充值成功 trade_no=%s user_id=%d quota_to_add=%d money=%.2f",
"Epay webhook failed to parse POST form path=%q client_ip=%s error=%q": "易支付 webhook POST 表单解析失败 path=%q client_ip=%s error=%q",
"Epay webhook failed to write response path=%q client_ip=%s error=%q": "易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q",
"Epay webhook failed to write response trade_no=%s client_ip=%s error=%q": "易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q",
"Epay webhook ignored event trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q": "易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q",
"Epay webhook params are empty path=%q client_ip=%s": "易支付 webhook 参数为空 path=%q client_ip=%s",
"Epay webhook received request path=%q client_ip=%s method=%s params=%q": "易支付 webhook 收到请求 path=%q client_ip=%s method=%s params=%q",
"Epay webhook rejected reason=webhook_disabled path=%q client_ip=%s": "易支付 webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
"Epay webhook signature verification failed path=%q client_ip=%s verify_error=%q": "易支付 webhook 验签失败 path=%q client_ip=%s verify_error=%q",
"Epay webhook signature verification failed path=%q client_ip=%s verify_status=false": "易支付 webhook 验签失败 path=%q client_ip=%s verify_status=false",
"Epay webhook signature verified trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q": "易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q",
"Error getting sql.DB from GORM: %v": "从 GORM 取得 sql.DB 失败: %v",
"Error pinging DB: %v": "数据库 ping 失败: %v",
"Error: Failed to decode base64 string": "base64 字符串解码失败",
"FRONTEND_BASE_URL is ignored on master node": "当前是主节点,FRONTEND_BASE_URL 设置已忽略",
"Failed to get binding count for provider %s: %s": "查询 OAuth 提供商 %s 的绑定用户数失败: %s",
"Failed to load custom OAuth providers: %s": "加载自定义 OAuth 提供商失败: %s",
"Failed to load rate limit script: %v": "加载限流脚本失败: %v",
"Failed to project plugin video for task %s": "读取任务 %s 的制品列表失败",
"Failed to query task %s: %s": "查询任务 %s 失败: %s",
"Failed to resolve plugin video content for task %s: %v": "任务 %s: 生成视频内容的下载请求失败: %v",
"Failed to stream image: %v": "向客户端转发图片失败: %v",
"Failed to stream task media: %v": "向客户端转发任务媒体文件失败: %v",
"Failed to update task %s: %s": "更新任务 %s 失败: %s",
"Failed to update video task %s: %s": "更新视频任务 %s 失败: %s",
"Failed to write to disk cache, falling back to memory: %v": "写入磁盘缓存失败,改用内存缓存: %v",
"File cached to disk: %s, size: %d bytes": "文件已缓存到磁盘: %s,大小: %d 字节",
"Fix null mj_id task error: %v": "修复 mj_id 为空的任务出错: %v",
"Fix null mj_id task success: %v": "修复 mj_id 为空的任务成功: %v",
"Fix null task_id task error: %v": "修复 task_id 为空的任务出错: %v",
"Fix null task_id task success: %v": "修复 task_id 为空的任务成功: %v",
"Gemini embedding request body: %s": "Gemini 嵌入请求体: %s",
"Gemini native embedding response body: %s": "Gemini 原生格式嵌入响应体: %s",
"Gemini native response body: %s": "Gemini 原生格式响应体: %s",
"Gemini request body: %s": "Gemini 请求体: %s",
"Gemini response body: %s": "Gemini 响应体: %s",
"Gemini responses response body: %s": "Gemini Responses 响应体: %s",
"Gemini stream ended unexpectedly: %s": "Gemini 流式响应异常中断: %s",
"Get Mjp Task parse body error2: %v, body: %s": "解析 Midjourney 任务查询响应失败: %v,响应体: %s",
"Get Mjp Task parse body error: %v": "读取 Midjourney 任务查询响应失败: %v",
"Get Suno Task parse body error: %v": "读取 Suno 任务查询响应失败: %v",
"Get Task Do req error: %v": "发送任务查询请求失败: %v",
"Get Task error: %v": "创建任务查询请求失败: %v",
"Get Task marshal body error: %v": "序列化任务查询请求体失败: %v",
"Get Task status code: %d": "任务查询返回状态码 %d",
"GetAllEnableAbilityWithChannels error: %v": "GetAllEnableAbilityWithChannels 出错: %v",
"GetPasskeyByCredentialID: database error for credential ID: %v": "GetPasskeyByCredentialID: 按凭证 ID 查询数据库出错: %v",
"GetPasskeyByCredentialID: empty credential ID": "GetPasskeyByCredentialID: 凭证 ID 为空",
"GetPasskeyByCredentialID: passkey not found for credential ID length %d": "GetPasskeyByCredentialID: 未找到凭证 ID 对应的通行密钥,凭证 ID 长度 %d",
"GetPasskeyByUserID: database error for user %d: %v": "GetPasskeyByUserID: 查询用户 %d 时数据库出错: %v",
"GetPasskeyByUserID: empty user ID": "GetPasskeyByUserID: 用户 ID 为空",
"GetPreferredModelOwnerChannelTypes error: %v": "GetPreferredModelOwnerChannelTypes 出错: %v",
"InitChannelCache failed: %s": "InitChannelCache 失败: %s",
"InitChannelCache panic: %v": "InitChannelCache 发生 panic: %v",
"InitChannelCache panic: %v, retrying once": "InitChannelCache 发生 panic: %v,重试一次",
"LoadFileSource starting for: %s": "LoadFileSource 开始加载: %s",
"Loaded %d custom OAuth providers": "已加载 %d 个自定义 OAuth 提供商",
"Loaded custom OAuth provider: %s (%s)": "已加载自定义 OAuth 提供商: %s (%s)",
"Local:": "本机地址:",
"Login database error for user %s: %v": "用户 %s 登录时数据库出错: %v",
"Midjourney action uses origin channel: id=%s, base_url=%s": "Midjourney 后续操作使用原任务所在的渠道: id=%s, base_url=%s",
"Midjourney refund succeeded but clearing the quota failed, task %s: %s": "Midjourney 退款成功但清除 quota 失败 task %s: %s",
"Midjourney task response ignored: unknown mj_id=%s": "上游返回了未知的 Midjourney 任务 mj_id=%s,已忽略",
"Network:": "网络地址:",
"New API %s started": "New API %s 已启动",
"No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.": "未找到 .env 文件,直接使用进程的环境变量。需要时可以新建 .env 文件,把变量写在里面。",
"No available channel in group %s for model %s at priorityRetry %d, trying next group": "分组 %s 没有模型 %s 的可用渠道 (priorityRetry=%d),尝试下一个分组",
"No update needed for task %s": "任务 %s 没有变化,跳过更新",
"OpenRouter parse failed from %s: %s": "解析 %s 返回的 OpenRouter 数据失败: %s",
"PasskeyLogin: userHandle parse error for credential, length: %d": "PasskeyLogin: 凭证的 userHandle 无法解析为用户 ID,长度: %d",
"Please set SESSION_SECRET to a random string.": "请将 SESSION_SECRET 设置为随机字符串。",
"REDIS_CONN_STRING not set, Redis is not enabled": "未设置 REDIS_CONN_STRING,Redis 未启用",
"Redis connected to %s": "Redis 已连接到 %s",
"Redis database: %d": "Redis 数据库: %d",
"Redis is enabled": "Redis 已启用",
"Redis ping test failed: %s": "Redis 连接测试失败: %s",
"RemoveDisabledFields Marshal error :%s": "RemoveDisabledFields 序列化失败 :%s",
"RemoveDisabledFields Unmarshal error :%s": "RemoveDisabledFields 反序列化失败 :%s",
"RemoveGeminiDisabledFields Marshal error: %s": "RemoveGeminiDisabledFields 序列化失败: %s",
"RemoveGeminiDisabledFields Unmarshal error: %s": "RemoveGeminiDisabledFields 反序列化失败: %s",
"Rerank request body: %s": "重排序请求体: %s",
"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 数据已发送",
"SSE ping error, stopping goroutine: %s": "SSE ping 出错,停止 goroutine: %s",
"SSE ping error: %s": "SSE ping 出错: %s",
"SSE ping goroutine panic recovered: %v": "SSE ping goroutine 发生 panic,已恢复: %v",
"SSE ping goroutine started": "SSE ping goroutine 已启动",
"SSE ping goroutine stopped": "SSE ping goroutine 已停止",
"SSE ping goroutine stopped by defer": "SSE ping goroutine 已由 defer 停止",
"SSE ping goroutine timeout, stopping": "SSE ping goroutine 超时,正在停止",
"SSE ping ticker stopped": "SSE ping ticker 已停止",
"SYNC_FREQUENCY not set, use default value 60": "未设置 SYNC_FREQUENCY,使用默认值 60",
"SetPayloadHash body: %s": "SetPayloadHash 请求体: %s",
"Stripe Checkout payment not completed, waiting for async result trade_no=%s payment_status=%s client_ip=%s": "Stripe Checkout 支付未完成,等待异步结果 trade_no=%s payment_status=%s client_ip=%s",
"Stripe async payment failed but local order not found trade_no=%s client_ip=%s": "Stripe 异步支付失败但本地订单不存在 trade_no=%s client_ip=%s",
"Stripe async payment failed but order not pending, ignored trade_no=%s status=%s client_ip=%s": "Stripe 异步支付失败但订单状态非 pending,忽略处理 trade_no=%s status=%s client_ip=%s",
"Stripe async payment failed but order payment gateway mismatch trade_no=%s payment_provider=%s client_ip=%s": "Stripe 异步支付失败但订单支付网关不匹配 trade_no=%s payment_provider=%s client_ip=%s",
"Stripe async payment failed event missing order number client_ip=%s": "Stripe 异步支付失败事件缺少订单号 client_ip=%s",
"Stripe async payment failed trade_no=%s client_ip=%s": "Stripe 异步支付失败 trade_no=%s client_ip=%s",
"Stripe async payment succeeded trade_no=%s client_ip=%s": "Stripe 异步支付成功 trade_no=%s client_ip=%s",
"Stripe checkout.completed has unexpected status, ignored trade_no=%s status=%s client_ip=%s": "Stripe checkout.completed 状态异常,忽略处理 trade_no=%s status=%s client_ip=%s",
"Stripe checkout.expired has unexpected status, ignored trade_no=%s status=%s": "Stripe checkout.expired 状态异常,忽略处理 trade_no=%s status=%s",
"Stripe checkout.expired missing order number": "Stripe checkout.expired 缺少订单号",
"Stripe failed to create Checkout Session user_id=%d trade_no=%s amount=%d error=%q": "Stripe 创建 Checkout Session 失败 user_id=%d trade_no=%s amount=%d error=%q",
"Stripe failed to create subscription payment link trade_no=%s plan_id=%d error=%q": "Stripe 订阅支付链接创建失败 trade_no=%s plan_id=%d error=%q",
"Stripe failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q": "Stripe 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q",
"Stripe failed to mark top-up order as failed trade_no=%s client_ip=%s error=%q": "Stripe 标记充值订单失败状态失败 trade_no=%s client_ip=%s error=%q",
"Stripe order completion missing order number client_ip=%s": "Stripe 完成订单时缺少订单号 client_ip=%s",
"Stripe subscription order expired trade_no=%s": "Stripe 订阅订单已过期 trade_no=%s",
"Stripe subscription order expiry processing failed trade_no=%s error=%q": "Stripe 订阅订单过期处理失败 trade_no=%s error=%q",
"Stripe subscription order processed trade_no=%s event_type=%s client_ip=%s": "Stripe 订阅订单处理成功 trade_no=%s event_type=%s client_ip=%s",
"Stripe subscription order processing failed trade_no=%s event_type=%s client_ip=%s error=%q": "Stripe 订阅订单处理失败 trade_no=%s event_type=%s client_ip=%s error=%q",
"Stripe top-up order created user_id=%d trade_no=%s amount=%d money=%.2f": "Stripe 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f",
"Stripe top-up order expired trade_no=%s": "Stripe 充值订单已过期 trade_no=%s",
"Stripe top-up order expiry processing failed trade_no=%s error=%q": "Stripe 充值订单过期处理失败 trade_no=%s error=%q",
"Stripe top-up order marked as failed trade_no=%s client_ip=%s": "Stripe 充值订单已标记为失败 trade_no=%s client_ip=%s",
"Stripe top-up order not found, cannot mark as expired trade_no=%s": "Stripe 充值订单不存在,无法标记过期 trade_no=%s",
"Stripe top-up processing failed trade_no=%s event_type=%s client_ip=%s error=%q": "Stripe 充值处理失败 trade_no=%s event_type=%s client_ip=%s error=%q",
"Stripe top-up succeeded trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s": "Stripe 充值成功 trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s",
"Stripe webhook failed to read request body path=%q client_ip=%s error=%q": "Stripe webhook 读取请求体失败 path=%q client_ip=%s error=%q",
"Stripe webhook ignored event event_type=%s client_ip=%s": "Stripe webhook 忽略事件 event_type=%s client_ip=%s",
"Stripe webhook received request path=%q client_ip=%s signature=%q body=%q": "Stripe webhook 收到请求 path=%q client_ip=%s signature=%q body=%q",
"Stripe webhook rejected reason=webhook_disabled path=%q client_ip=%s": "Stripe webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
"Stripe webhook signature verification failed path=%q client_ip=%s error=%q": "Stripe webhook 验签失败 path=%q client_ip=%s error=%q",
"Stripe webhook signature verified event_type=%s client_ip=%s path=%q": "Stripe webhook 验签成功 event_type=%s client_ip=%s path=%q",
"Successfully migrated %s.%s to decimal(10,6)": "字段 %s.%s 的类型已改为 decimal(10,6)",
"Successfully migrated %s.%s to text": "字段 %s.%s 的类型已改为 text",
"Task %s CAS lost or no-op update, skip billing": "任务 %s 的状态已由其他进程更新或没有变化,跳过计费",
"Task %s failed": "任务 %s 失败",
"Task %s failed: %s": "任务 %s 失败: %s",
"Task %s not found in taskM": "任务 %s 不在本次轮询的任务列表中",
"Token has IP restrictions, checking client IP %s": "令牌设置了 IP 白名单,检查客户端 IP %s",
"TokenAuth GetUserCache error for user %d: %v": "TokenAuth GetUserCache 获取用户 %d 出错: %v",
"TokenAuth ValidateUserToken database error: %s": "TokenAuth ValidateUserToken 数据库出错: %s",
"TokenAuthReadOnly GetTokenByKey database error: %s": "TokenAuthReadOnly GetTokenByKey 数据库出错: %s",
"TokenAuthReadOnly GetUserCache error for user %d: %v": "TokenAuthReadOnly GetUserCache 获取用户 %d 出错: %v",
"Truncate abilities failed: %s": "修复渠道一致性: 清空 abilities 表失败: %s",
"Trying to read %d bytes to determine file type": "读取前 %d 字节来判断文件类型",
"USER_SESSION_ISSUANCE_WINDOW_SECONDS exceeds revoked retention; configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d": "USER_SESSION_ISSUANCE_WINDOW_SECONDS 大于已吊销会话的保留时长,已按保留时长生效: configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d",
"USER_SESSION_REVOKED_RETENTION_DAYS is too large, using default value: %d": "USER_SESSION_REVOKED_RETENTION_DAYS 的值过大,已使用默认值: %d",
"UpdateBatchTasks fail: %s": "UpdateBatchTasks 失败: %s",
"UpdateMidjourneyTask error: %v": "UpdateMidjourneyTask 出错: %v",
"UpdateMidjourneyTask task error: %s": "UpdateMidjourneyTask 更新任务出错: %s",
"UpdateSunoTask error: %v": "UpdateSunoTask 出错: %v",
"UpdateSunoTask task error: %s": "UpdateSunoTask 更新任务出错: %s",
"UpdateVideoTask error: %v": "UpdateVideoTask 出错: %v",
"UpdateVideoTasks fail: %s": "UpdateVideoTasks 失败: %s",
"UpdateWithStatus failed for task %s: %s": "更新任务 %s 的状态失败 (UpdateWithStatus): %s",
"UpsertPasskeyCredential: failed to create credential for user %d: %v": "UpsertPasskeyCredential: 为用户 %d 创建凭证失败: %v",
"UpsertPasskeyCredential: failed to delete existing credential for user %d: %v": "UpsertPasskeyCredential: 删除用户 %d 的现有凭证失败: %v",
"ValidateUserToken: failed to get token: %s": "ValidateUserToken: 获取令牌失败: %s",
"WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string.": "警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。",
"WARNING: TRUSTED_PROXIES is unset or blank; trusting loopback, RFC 1918, and IPv6 ULA proxy addresses for compatibility. Set TRUSTED_PROXIES=none to trust no proxies, or configure explicit proxy IPs/CIDRs to replace these defaults.": "警告:TRUSTED_PROXIES 未设置或为空。为兼容已有部署,来自回环地址、RFC 1918 内网地址和 IPv6 ULA 地址的代理会被信任。设置 TRUSTED_PROXIES=none 表示任何代理都不信任,也可以填写代理的 IP 或 CIDR 来替换这组默认值。",
"Waffo Pancake failed to create checkout session user_id=%d trade_no=%s error=%q": "Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q",
"Waffo Pancake failed to create plan product store_id=%q name=%q amount=%q error=%q": "Waffo Pancake 创建套餐产品失败 store_id=%q name=%q amount=%q error=%q",
"Waffo Pancake failed to create store and product orphan_store=%t store_id=%q error=%q": "Waffo Pancake 创建店铺与产品失败 orphan_store=%t store_id=%q error=%q",
"Waffo Pancake failed to create subscription checkout session user_id=%d plan_id=%d trade_no=%s error=%q": "Waffo Pancake 订阅结账会话创建失败 user_id=%d plan_id=%d trade_no=%s error=%q",
"Waffo Pancake failed to create subscription order user_id=%d plan_id=%d trade_no=%s error=%q": "Waffo Pancake 订阅订单创建失败 user_id=%d plan_id=%d trade_no=%s error=%q",
"Waffo Pancake failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q": "Waffo Pancake 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q",
"Waffo Pancake failed to fetch store and product catalog error=%q": "Waffo Pancake 拉取店铺与产品目录失败 error=%q",
"Waffo Pancake failed to fetch subscription product list store_id=%q error=%q": "Waffo Pancake 拉取订阅产品列表失败 store_id=%q error=%q",
"Waffo Pancake failed to save config store_id=%q product_id=%q error=%q": "Waffo Pancake 保存配置失败 store_id=%q product_id=%q error=%q",
"Waffo Pancake subscription completed trade_no=%s event_id=%s order_id=%s client_ip=%s": "Waffo Pancake 订阅完成 trade_no=%s event_id=%s order_id=%s client_ip=%s",
"Waffo Pancake subscription completion failed trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q": "Waffo Pancake 订阅完成失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q",
"Waffo Pancake subscription order created user_id=%d plan_id=%d trade_no=%s session_id=%s money=%.2f": "Waffo Pancake 订阅订单创建成功 user_id=%d plan_id=%d trade_no=%s session_id=%s money=%.2f",
"Waffo Pancake top-up order created user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f": "Waffo Pancake 充值订单创建成功 user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f",
"Waffo Pancake top-up processing failed trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q": "Waffo Pancake 充值处理失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q",
"Waffo Pancake top-up succeeded trade_no=%s event_id=%s order_id=%s client_ip=%s": "Waffo Pancake 充值成功 trade_no=%s event_id=%s order_id=%s client_ip=%s",
"Waffo Pancake webhook env mismatch expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s": "Waffo Pancake webhook 环境不匹配 expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s",
"Waffo Pancake webhook failed to read request body path=%q client_ip=%s error=%q": "Waffo Pancake webhook 读取请求体失败 path=%q client_ip=%s error=%q",
"Waffo Pancake webhook failed to resolve order event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q": "Waffo Pancake webhook 订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
"Waffo Pancake webhook failed to resolve subscription order event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q": "Waffo Pancake webhook 订阅订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
"Waffo Pancake webhook invalid env path segment env=%q path=%q client_ip=%s": "Waffo Pancake webhook 路径环境段无效 env=%q path=%q client_ip=%s",
"Waffo Pancake webhook received request path=%q client_ip=%s signature=%q body=%q": "Waffo Pancake webhook 收到请求 path=%q client_ip=%s signature=%q body=%q",
"Waffo Pancake webhook rejected reason=webhook_disabled path=%q client_ip=%s": "Waffo Pancake webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
"Waffo Pancake webhook signature verification failed path=%q client_ip=%s signature=%q body=%q error=%q": "Waffo Pancake webhook 验签失败 path=%q client_ip=%s signature=%q body=%q error=%q",
"Waffo Pancake webhook signature verified event_type=%s event_id=%s order_id=%s client_ip=%s": "Waffo Pancake webhook 验签成功 event_type=%s event_id=%s order_id=%s client_ip=%s",
"Waffo SDK initialization failed user_id=%d trade_no=%s error=%q": "Waffo SDK 初始化失败 user_id=%d trade_no=%s error=%q",
"Waffo failed to create order user_id=%d trade_no=%s error=%q": "Waffo 创建订单失败 user_id=%d trade_no=%s error=%q",
"Waffo failed to create top-up order user_id=%d trade_no=%s amount=%d error=%q": "Waffo 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q",
"Waffo failed to mark order as failed trade_no=%s error=%q": "Waffo 标记失败订单状态失败 trade_no=%s error=%q",
"Waffo failed to parse payment callback payload event_type=%s client_ip=%s error=%q body=%q": "Waffo 支付回调载荷解析失败 event_type=%s client_ip=%s error=%q body=%q",
"Waffo invalid payment method index user_id=%d pay_method_index=%d method_count=%d": "Waffo 支付方式索引无效 user_id=%d pay_method_index=%d method_count=%d",
"Waffo invalid payment method user_id=%d pay_method_type=%s pay_method_name=%q": "Waffo 支付方式无效 user_id=%d pay_method_type=%s pay_method_name=%q",
"Waffo order creation rejected user_id=%d trade_no=%s code=%s message=%q response=%q": "Waffo 创建订单业务失败 user_id=%d trade_no=%s code=%s message=%q response=%q",
"Waffo order not successful, top-up skipped trade_no=%s order_status=%s client_ip=%s": "Waffo 订单状态非成功,忽略充值 trade_no=%s order_status=%s client_ip=%s",
"Waffo top-up order created user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q": "Waffo 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q",
"Waffo top-up processing failed trade_no=%s client_ip=%s error=%q": "Waffo 充值处理失败 trade_no=%s client_ip=%s error=%q",
"Waffo top-up succeeded trade_no=%s client_ip=%s": "Waffo 充值成功 trade_no=%s client_ip=%s",
"Waffo webhook SDK initialization failed path=%q client_ip=%s error=%q": "Waffo webhook SDK 初始化失败 path=%q client_ip=%s error=%q",
"Waffo webhook failed to parse path=%q client_ip=%s error=%q body=%q": "Waffo webhook 解析失败 path=%q client_ip=%s error=%q body=%q",
"Waffo webhook failed to read request body path=%q client_ip=%s error=%q": "Waffo webhook 读取请求体失败 path=%q client_ip=%s error=%q",
"Waffo webhook ignored event event_type=%s client_ip=%s": "Waffo webhook 忽略事件 event_type=%s client_ip=%s",
"Waffo webhook received request path=%q client_ip=%s signature=%q body=%q": "Waffo webhook 收到请求 path=%q client_ip=%s signature=%q body=%q",
"Waffo webhook rejected reason=webhook_disabled path=%q client_ip=%s": "Waffo webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s",
"Waffo webhook signature verification failed path=%q client_ip=%s signature=%q body=%q": "Waffo webhook 验签失败 path=%q client_ip=%s signature=%q body=%q",
"Waffo webhook verified and parsed event_type=%s merchant_order_id=%s order_status=%s client_ip=%s": "Waffo webhook 验签并解析成功 event_type=%s merchant_order_id=%s order_status=%s client_ip=%s",
"Warning: Refresh cookie is not secure and refresh/logout Origin validation is disabled. Please set SESSION_COOKIE_SECURE=true in production.": "警告:SESSION_COOKIE_SECURE 未开启。登录 Cookie 可经 HTTP 明文传输,续期登录和退出登录接口接受任意来源(Origin)的请求。生产环境请设置 SESSION_COOKIE_SECURE=true。",
"Warning: failed to migrate %s.%s to decimal: %v": "警告:把字段 %s.%s 的类型改为 decimal 失败: %v",
"Warning: failed to query metadata for %s.%s: %v": "警告:查询字段 %s.%s 的类型信息失败: %v",
"[OAuth-Discord] ExchangeToken decode error: %s": "[OAuth-Discord] ExchangeToken 解析响应失败: %s",
"[OAuth-Discord] ExchangeToken error: %s": "[OAuth-Discord] ExchangeToken 请求失败: %s",
"[OAuth-Discord] ExchangeToken failed: empty access token": "[OAuth-Discord] ExchangeToken 失败: 响应里没有 access token",
"[OAuth-Discord] ExchangeToken response status: %d": "[OAuth-Discord] ExchangeToken 响应状态码: %d",
"[OAuth-Discord] ExchangeToken success: scope=%s": "[OAuth-Discord] ExchangeToken 成功: scope=%s",
"[OAuth-Discord] GetUserInfo decode error: %s": "[OAuth-Discord] GetUserInfo 解析响应失败: %s",
"[OAuth-Discord] GetUserInfo error: %s": "[OAuth-Discord] GetUserInfo 请求失败: %s",
"[OAuth-Discord] GetUserInfo failed: empty user fields": "[OAuth-Discord] GetUserInfo 失败: 返回的用户信息为空",
"[OAuth-Discord] GetUserInfo failed: status=%d": "[OAuth-Discord] GetUserInfo 失败: status=%d",
"[OAuth-Discord] GetUserInfo response status: %d": "[OAuth-Discord] GetUserInfo 响应状态码: %d",
"[OAuth-Discord] GetUserInfo success: uid=%s, username=%s, name=%s": "[OAuth-Discord] GetUserInfo 成功: uid=%s, username=%s, name=%s",
"[OAuth-Discord] GetUserInfo: fetching user info": "[OAuth-Discord] GetUserInfo: 开始获取用户信息",
"[OAuth-Generic-%s] ExchangeToken OAuth error: %s - %s": "[OAuth-Generic-%s] ExchangeToken 提供商返回错误: %s - %s",
"[OAuth-Generic-%s] ExchangeToken error: %s": "[OAuth-Generic-%s] ExchangeToken 请求失败: %s",
"[OAuth-Generic-%s] ExchangeToken failed: empty access token": "[OAuth-Generic-%s] ExchangeToken 失败: 响应里没有 access token",
"[OAuth-Generic-%s] ExchangeToken parse error: %s": "[OAuth-Generic-%s] ExchangeToken 解析响应失败: %s",
"[OAuth-Generic-%s] ExchangeToken read body error: %s": "[OAuth-Generic-%s] ExchangeToken 读取响应体失败: %s",
"[OAuth-Generic-%s] ExchangeToken response status: %d": "[OAuth-Generic-%s] ExchangeToken 响应状态码: %d",
"[OAuth-Generic-%s] ExchangeToken success: scope=%s": "[OAuth-Generic-%s] ExchangeToken 成功: scope=%s",
"[OAuth-Generic-%s] GetUserInfo error: %s": "[OAuth-Generic-%s] GetUserInfo 请求失败: %s",
"[OAuth-Generic-%s] GetUserInfo failed: empty user ID (field: %s)": "[OAuth-Generic-%s] GetUserInfo 失败: 用户 ID 为空 (字段: %s)",
"[OAuth-Generic-%s] GetUserInfo failed: status=%d": "[OAuth-Generic-%s] GetUserInfo 失败: status=%d",
"[OAuth-Generic-%s] GetUserInfo read body error: %s": "[OAuth-Generic-%s] GetUserInfo 读取响应体失败: %s",
"[OAuth-Generic-%s] GetUserInfo response status: %d": "[OAuth-Generic-%s] GetUserInfo 响应状态码: %d",
"[OAuth-Generic-%s] GetUserInfo success: id=%s, username=%s, name=%s, email=%s": "[OAuth-Generic-%s] GetUserInfo 成功: id=%s, username=%s, name=%s, email=%s",
"[OAuth-Generic-%s] GetUserInfo: fetching user info from %s": "[OAuth-Generic-%s] GetUserInfo: 开始从 %s 获取用户信息",
"[OAuth-Generic-%s] access denied by policy: field=%s op=%s expected=%v current=%v": "[OAuth-Generic-%s] 不满足访问策略,已拒绝登录: field=%s op=%s expected=%v current=%v",
"[OAuth-Generic-%s] invalid access policy: %s": "[OAuth-Generic-%s] 访问策略配置无效: %s",
"[OAuth-GitHub] ExchangeToken decode error: %s": "[OAuth-GitHub] ExchangeToken 解析响应失败: %s",
"[OAuth-GitHub] ExchangeToken error: %s": "[OAuth-GitHub] ExchangeToken 请求失败: %s",
"[OAuth-GitHub] ExchangeToken failed: empty access token": "[OAuth-GitHub] ExchangeToken 失败: 响应里没有 access token",
"[OAuth-GitHub] ExchangeToken response status: %d": "[OAuth-GitHub] ExchangeToken 响应状态码: %d",
"[OAuth-GitHub] ExchangeToken success: scope=%s": "[OAuth-GitHub] ExchangeToken 成功: scope=%s",
"[OAuth-GitHub] GetUserInfo decode error: %s": "[OAuth-GitHub] GetUserInfo 解析响应失败: %s",
"[OAuth-GitHub] GetUserInfo error: %s": "[OAuth-GitHub] GetUserInfo 请求失败: %s",
"[OAuth-GitHub] GetUserInfo failed: empty id or login field": "[OAuth-GitHub] GetUserInfo 失败: id 或 login 字段为空",
"[OAuth-GitHub] GetUserInfo failed: status=%d, body=%s": "[OAuth-GitHub] GetUserInfo 失败: status=%d, body=%s",
"[OAuth-GitHub] GetUserInfo response status: %d": "[OAuth-GitHub] GetUserInfo 响应状态码: %d",
"[OAuth-GitHub] GetUserInfo success: id=%d, login=%s, name=%s, email=%s": "[OAuth-GitHub] GetUserInfo 成功: id=%d, login=%s, name=%s, email=%s",
"[OAuth-GitHub] GetUserInfo: fetching user info": "[OAuth-GitHub] GetUserInfo: 开始获取用户信息",
"[OAuth-GitHub] GetVerifiedEmails decode error: %s": "[OAuth-GitHub] GetVerifiedEmails 解析响应失败: %s",
"[OAuth-GitHub] GetVerifiedEmails error: %s": "[OAuth-GitHub] GetVerifiedEmails 请求失败: %s",
"[OAuth-GitHub] GetVerifiedEmails failed: status=%d": "[OAuth-GitHub] GetVerifiedEmails 失败: status=%d",
"[OAuth-GitHub] GetVerifiedEmails success: verified=%d": "[OAuth-GitHub] GetVerifiedEmails 成功: verified=%d",
"[OAuth-LinuxDO] ExchangeToken decode error: %s": "[OAuth-LinuxDO] ExchangeToken 解析响应失败: %s",
"[OAuth-LinuxDO] ExchangeToken error: %s": "[OAuth-LinuxDO] ExchangeToken 请求失败: %s",
"[OAuth-LinuxDO] ExchangeToken failed: %s": "[OAuth-LinuxDO] ExchangeToken 失败: %s",
"[OAuth-LinuxDO] ExchangeToken response status: %d": "[OAuth-LinuxDO] ExchangeToken 响应状态码: %d",
"[OAuth-LinuxDO] ExchangeToken success": "[OAuth-LinuxDO] ExchangeToken 成功",
"[OAuth-LinuxDO] GetUserInfo decode error: %s": "[OAuth-LinuxDO] GetUserInfo 解析响应失败: %s",
"[OAuth-LinuxDO] GetUserInfo error: %s": "[OAuth-LinuxDO] GetUserInfo 请求失败: %s",
"[OAuth-LinuxDO] GetUserInfo failed: invalid user id": "[OAuth-LinuxDO] GetUserInfo 失败: 用户 id 无效",
"[OAuth-LinuxDO] GetUserInfo response status: %d": "[OAuth-LinuxDO] GetUserInfo 响应状态码: %d",
"[OAuth-LinuxDO] GetUserInfo success: id=%d, username=%s": "[OAuth-LinuxDO] GetUserInfo 成功: id=%d, username=%s",
"[OAuth-LinuxDO] GetUserInfo: trust level too low (required=%d, current=%d)": "[OAuth-LinuxDO] GetUserInfo: 信任级别不足 (required=%d, current=%d)",
"[OAuth-OIDC] ExchangeToken decode error: %s": "[OAuth-OIDC] ExchangeToken 解析响应失败: %s",
"[OAuth-OIDC] ExchangeToken error: %s": "[OAuth-OIDC] ExchangeToken 请求失败: %s",
"[OAuth-OIDC] ExchangeToken failed: empty access token": "[OAuth-OIDC] ExchangeToken 失败: 响应里没有 access token",
"[OAuth-OIDC] ExchangeToken response status: %d": "[OAuth-OIDC] ExchangeToken 响应状态码: %d",
"[OAuth-OIDC] ExchangeToken success: scope=%s": "[OAuth-OIDC] ExchangeToken 成功: scope=%s",
"[OAuth-OIDC] GetUserInfo decode error: %s": "[OAuth-OIDC] GetUserInfo 解析响应失败: %s",
"[OAuth-OIDC] GetUserInfo error: %s": "[OAuth-OIDC] GetUserInfo 请求失败: %s",
"[OAuth-OIDC] GetUserInfo failed: empty fields (sub=%s, email=%s)": "[OAuth-OIDC] GetUserInfo 失败: 字段为空 (sub=%s, email=%s)",
"[OAuth-OIDC] GetUserInfo failed: status=%d": "[OAuth-OIDC] GetUserInfo 失败: status=%d",
"[OAuth-OIDC] GetUserInfo response status: %d": "[OAuth-OIDC] GetUserInfo 响应状态码: %d",
"[OAuth-OIDC] GetUserInfo success: sub=%s, username=%s, name=%s, email=%s": "[OAuth-OIDC] GetUserInfo 成功: sub=%s, username=%s, name=%s, email=%s",
"[OAuth] Failed to load verified emails for user %d: %s": "[OAuth] 加载用户 %d 的已验证邮箱失败: %s",
"access token last-use update failed (token_id=%d): %v": "更新访问令牌最后使用时间失败 (token_id=%d): %v",
"access token route is not declared: %s": "这条路由没有声明访问令牌的访问规则,已拒绝: %s",
"admin reset subscription plan %d quota: reset_count=%d user_count=%d advance_reset_time=%t": "管理员重置了订阅套餐 %d 的额度: reset_count=%d user_count=%d advance_reset_time=%t",
"admin subscription creation": "管理员创建订阅",
"admin subscription deletion": "管理员删除订阅",
"admin subscription update": "管理员修改订阅",
"api_version not found, using default: %s": "未找到 api_version,使用默认值: %s",
"audit actor role unavailable (request_id=%s, actor_role=%d)": "无法识别审计日志的操作者角色 (request_id=%s, actor_role=%d)",
"audit log write failed (request_id=%s): %v": "审计日志写入失败 (request_id=%s): %v",
"audit log write failed (request_id=%s): log database unavailable": "审计日志写入失败 (request_id=%s): 日志数据库不可用",
"auth session internal error (%s %s): %v": "登录会话接口内部错误 (%s %s): %v",
"authorization policy %d has an unsupported legacy scope; loaded as deny; review the stored policy before granting access": "授权策略 %d 带有已不支持的旧范围设置,已按拒绝(deny)加载。放行前请先检查这条已保存的策略。",
"bad response status code %d with empty error message, body: %s": "上游返回状态码 %d,错误信息为空,响应体: %s",
"bad response status code %d, body: %s": "上游返回异常状态码 %d,响应体: %s",
"batch update enabled with interval %ss": "已启用批量更新,每 %s 秒执行一次",
"batch update finished": "批量更新完成",
"batch update overflow: type=%d id=%d old=%d value=%d": "批量更新的累加值溢出: type=%d id=%d old=%d value=%d",
"batch update started": "批量更新开始",
"billable tool count ignored: tool=%s count=%d": "工具计费次数无效,已忽略: tool=%s count=%d",
"build initial plugin router: %s": "初始化插件路由失败: %s",
"build request failed: %s": "创建请求失败: %s",
"build task plugin native submit view failed: %s": "任务插件提交响应: 构建任务数据失败: %s",
"build task protocol relay info failed: %s": "任务协议: 构建请求上下文失败: %s",
"build task protocol renderer context failed": "任务协议: 构建渲染钩子的上下文失败",
"build task protocol view failed: %s": "任务协议: 构建任务数据失败: %s",
"channel #%d failed to update async tasks: %s": "渠道 #%d 更新异步任务失败: %s",
"channel #%d has %d unfinished tasks": "渠道 #%d 未完成的任务有: %d",
"channel %d polling index: %d": "渠道 %d 的多密钥轮询位置: %d",
"channel %q (#%d) failed, disabling it, reason: %s": "通道「%s」(#%d)发生错误,准备禁用,原因:%s",
"channel %q (#%d) has automatic disabling turned off, skipping": "通道「%s」(#%d)未启用自动禁用功能,跳过禁用操作",
"channel affinity cache delete current failed: err=%v": "渠道亲和性缓存: 删除当前请求的记录失败: err=%v",
"channel affinity cache delete many failed: err=%v": "渠道亲和性缓存: 批量删除失败: err=%v",
"channel affinity cache get failed: key=%s, err=%v": "渠道亲和性缓存: 读取失败: key=%s, err=%v",
"channel affinity cache list keys failed: err=%v": "渠道亲和性缓存: 列出缓存键失败: err=%v",
"channel affinity cache set failed: key=%s, err=%v": "渠道亲和性缓存: 写入失败: key=%s, err=%v",
"channel error (channel #%d, status code: %d): %s": "渠道请求出错 (渠道 #%d,状态码 %d): %s",
"channel pin overridden: winning_source=%s winning_channel_id=%d overridden_source=%s overridden_channel_id=%d": "请求被多处指定了渠道,已按优先级采用其中一个: winning_source=%s winning_channel_id=%d overridden_source=%s overridden_channel_id=%d",
"channel test bad response: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v": "渠道测试响应异常: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v",
"channels synced from database": "已从数据库同步渠道",
"channels update done": "所有渠道的余额更新完成",
"charging the difference after pre-consume: %s (actual %s, pre-consumed %s)": "预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)",
"claude response usage is not complete, maybe upstream error": "Claude 响应里的用量信息不完整,可能是上游出错",
"closed %d active websocket connection(s), channels=%v, kinds=%v, reason=%s": "已关闭 %d 个使用中的 WebSocket 连接: channels=%v, kinds=%v, reason=%s",
"codex credential auto-refresh task started: tick=%s threshold=%s": "Codex 凭证自动刷新任务已启动: tick=%s threshold=%s",
"codex credential auto-refresh: InitChannelCache panic: %v": "Codex 凭证自动刷新: InitChannelCache 发生 panic: %v",
"codex credential auto-refresh: channel_id=%d name=%s refresh failed: %v": "Codex 凭证自动刷新: channel_id=%d name=%s 刷新失败: %v",
"codex credential auto-refresh: channel_id=%d name=%s refreshed, expires_at=%s": "Codex 凭证自动刷新: channel_id=%d name=%s 已刷新,expires_at=%s",
"codex credential auto-refresh: query channels failed: %v": "Codex 凭证自动刷新: 查询渠道失败: %v",
"codex credential auto-refresh: scanned=%d refreshed=%d": "Codex 凭证自动刷新: scanned=%d refreshed=%d",
"compile task plugin %s@%s: %v": "编译任务插件 %s@%s 失败: %v",
"configure plugin router trusted proxies: %s": "为插件路由设置可信代理失败: %s",
"conversion diagnostic: code=%q severity=%q from=%q to=%q path=%q message=%q": "格式转换诊断: code=%q severity=%q from=%q to=%q path=%q message=%q",
"conversion diagnostics truncated after %d distinct entries": "格式转换诊断已达 %d 条上限,后续不再记录",
"converted embedding request body: %s": "转换后的嵌入请求体: %s",
"cpu usage too high": "CPU 使用率过高",
"creem topup": "Creem 充值",
"creem topup failed: %s": "Creem 充值失败: %s",
"dashboard authentication error: %s": "控制台登录校验出错: %s",
"data handler goroutine panic: %v": "数据处理 goroutine 发生 panic: %v",
"database migration started": "开始数据库迁移",
"do request failed: %s": "请求上游失败: %s",
"downloading file from worker: %s, reason: %s": "通过 Worker 下载文件: %s,原因: %s",
"downloading from origin: %s, reason: %s": "直接从源地址下载: %s,原因: %s",
"encode task plugin native submit view failed: %s": "任务插件提交响应: 序列化任务数据失败: %s",
"encode task protocol view failed: %s": "任务协议: 序列化任务数据失败: %s",
"epay topup": "易支付充值",
"epay topup failed: %s": "易支付充值失败: %s",
"error adjusting token quota after funding settled (userId=%d, tokenId=%d, delta=%d): %s": "资金来源已结算,调整令牌额度时出错 (userId=%d, tokenId=%d, delta=%d): %s",
"error closing websocket connection: %s": "关闭 WebSocket 连接出错: %s",
"error consuming Midjourney quota: %s": "Midjourney 扣费出错: %s",
"error converting Claude stream response: %s": "转换 Claude 流式响应出错: %s",
"error converting final Gemini stream response: %s": "转换 Gemini 流式响应的最后一段出错: %s",
"error creating Gemini stream state: %s": "初始化 Gemini 流式转换状态出错: %s",
"error finalizing Gemini stream response: %s": "生成 Gemini 流式响应的结束段出错: %s",
"error handling last response: %s, lastStreamData: [%s]": "处理最后一段流数据出错: %s, lastStreamData: [%s]",
"error handling stream format: %s": "转换并发送流数据出错: %s",
"error marshalling chats: %s": "序列化聊天配置出错: %s",
"error marshalling model ratio: %s": "序列化模型倍率出错: %s",
"error marshalling stream response: %s": "序列化流式响应出错: %s",
"error marshalling topup group ratio: %s": "序列化充值分组倍率出错: %s",
"error marshalling user groups: %s": "序列化用户分组出错: %s",
"error processing stream token data: %s": "统计流数据的 token 出错: %s",
"error reading stream response: %s": "读取流式响应出错: %s",
"error reading stream: %s": "读取流式响应出错: %s",
"error refunding billing source: %s": "退还资金来源出错: %s",
"error refunding subscription extra reserved quota: %s": "退还订阅额外预扣的额度出错: %s",
"error refunding token quota: %s": "退还令牌额度出错: %s",
"error rolling back subscription funding reserve: %s": "回滚订阅预扣额度出错: %s",
"error rolling back token quota (userId=%d, tokenId=%d, amount=%d, fundingErr=%s): %s": "回滚令牌额度出错 (userId=%d, tokenId=%d, amount=%d, fundingErr=%s): %s",
"error rolling back wallet funding reserve: %s": "回滚钱包预扣额度出错: %s",
"error sending final Gemini stream response: %s": "发送 Gemini 流式响应的最后一段出错: %s",
"error sending finalized Gemini stream response: %s": "发送 Gemini 流式响应的结束段出错: %s",
"error sending stream response: %s": "发送流式响应出错: %s",
"error settling Midjourney quota: %s": "Midjourney 结算出错: %s",
"error settling billing: %s": "计费结算出错: %s",
"error unmarshalling stream response: %s": "解析流式响应出错: %s",
"error: %s": "错误: %s",
"error_rendering_final_usage_response: %s": "发送用量数据出错: %s",
"error_rendering_stream_response: %s": "发送流式响应出错: %s",
"error_scanning_stream_response: %s": "读取流式响应出错: %s",
"error_unmarshalling_stream_response: %s": "解析流式响应出错: %s",
"expected Claude stream responses, got %T": "这里需要 Claude 流式响应,实际类型是 %T",
"fail to decode image config(gif, jpg, png): %s": "解析图片头信息失败 (gif, jpg, png): %s",
"fail to decode image config(webp): %s": "解析图片头信息失败 (webp): %s",
"fail to get file type from url: %s, error: %s": "获取文件类型失败,URL: %s,错误: %s",
"fail to get image from url: %s": "下载图片失败: %s",
"failed to add user field: %s": "写入表单字段 user 失败: %s",
"failed to adjust funding for the difference, task %s: %s": "差额结算资金调整失败 task %s: %s",
"failed to adjust token quota (delta=%d, task=%s): %s": "调整令牌额度失败 (delta=%d, task=%s): %s",
"failed to batch update token quota: %s": "批量更新令牌额度失败: %s",
"failed to batch update user quota, used quota and request count: %s": "批量更新用户额度、已用额度和请求次数失败: %s",
"failed to bind upstream request: %s": "解析上游倍率同步的请求参数失败: %s",
"failed to cache refresh-reuse session revoke: %s": "刷新令牌被重复使用,会话已吊销,写入缓存失败: %s",
"failed to charge violation fee: %s": "违规扣费失败: %s",
"failed to check channel status before closing active websockets: channel_id=%d, error=%v": "关闭 WebSocket 连接前查询渠道状态失败: channel_id=%d, error=%v",
"failed to check notification limit: %s": "检查通知频率限制失败: %s",
"failed to check the successful request limit: %s": "检查成功请求数限制失败: %s",
"failed to check the total request limit: %s": "检查总请求数限制失败: %s",
"failed to cleanup expired perf metrics: %s": "清理过期性能指标失败: %s",
"failed to clone channel: %s": "复制渠道失败: %s",
"failed to close database: %s": "关闭数据库失败: %s",
"failed to close response body: %s": "关闭响应体失败: %s",
"failed to compensate reserved token quota: result=%d error=%v": "回补缓存中已预扣的令牌额度失败: result=%d error=%v",
"failed to compensate reserved user quota: result=%d error=%v": "回补缓存中已预扣的用户额度失败: result=%d error=%v",
"failed to configure trusted proxies: %s": "设置可信代理失败: %s",
"failed to copy file content: %s": "复制文件内容失败: %s",
"failed to copy response body: %s": "复制响应体失败: %s",
"failed to count backup codes: %s": "获取备用码数量失败: %s",
"failed to count channel types: %s": "统计渠道类型失败: %s",
"failed to count channels: %s": "统计渠道数量失败: %s",
"failed to count hourly user session issuance: %s": "统计近一小时新建的登录会话数失败: %s",
"failed to count search tokens: %s": "统计令牌搜索结果数量失败: %s",
"failed to count search topups: %s": "统计充值记录搜索结果数量失败: %s",
"failed to count tags: %s": "统计标签数量失败: %s",
"failed to count user logs: %s": "统计用户日志数量失败: %s",
"failed to count user tokens: %s": "统计用户令牌数量失败: %s",
"failed to create disk storage, falling back to memory: %v": "请求体写入磁盘失败,改为保存在内存: %v",
"failed to create form file: %s": "创建表单文件失败: %s",
"failed to create request: %s": "创建请求失败: %s",
"failed to create setup record: %s": "创建系统初始化记录失败: %s",
"failed to create temp file: %s": "创建临时文件失败: %s",
"failed to create the pprof directory: %s": "创建pprof文件夹失败 %s",
"failed to create the pprof file: %s": "创建pprof文件失败 %s",
"failed to decode base64: %s": "base64 解码失败: %s",
"failed to decode response: %s": "解析响应失败: %s",
"failed to decrease token quota: %s": "扣减令牌额度失败: %s",
"failed to decrease user quota cache after subscription balance purchase: %s": "余额购买订阅后,扣减用户额度缓存失败: %s",
"failed to decrease user quota: %s": "扣减用户额度失败: %s",
"failed to delete expired access tokens: %s": "清理过期的访问令牌失败: %s",
"failed to delete expired authentication flows: %s": "清理过期的登录验证流程失败: %s",
"failed to delete expired user sessions: %s": "清理过期的登录会话失败: %s",
"failed to delete old revoked user sessions: %s": "清理超过保留期的已吊销会话失败: %s",
"failed to download file from %s, status code: %d": "从 %s 下载文件失败,状态码: %d",
"failed to encode log admin info: %s": "序列化日志的 admin_info 失败: %s",
"failed to fetch codex reset credits": "获取 Codex 重置次数失败",
"failed to fetch codex usage": "获取 Codex 用量失败",
"failed to finalize bulk user session revoke tombstone: %s": "批量吊销会话后,写入吊销缓存失败: %s",
"failed to finalize refresh-authenticated session revoke tombstone: %s": "按刷新令牌吊销会话后,写入吊销缓存失败: %s",
"failed to finalize user session revoke tombstone: %s": "吊销会话后,写入吊销缓存失败: %s",
"failed to flush perf metric bucket model=%s group=%s bucket=%d: %s": "保存性能指标失败 model=%s group=%s bucket=%d: %s",
"failed to generate backup codes: %s": "生成备用码失败: %s",
"failed to generate the default sidebar config: %s": "生成默认边栏配置失败: %s",
"failed to generate token key: %s": "生成令牌密钥失败: %s",
"failed to get audio duration: %v": "获取音频时长失败: %v",
"failed to get channel by id: %s": "按 ID 查询渠道失败: %s",
"failed to get channels by tag: %s": "按标签查询渠道失败: %s",
"failed to get channels: %s": "查询渠道失败: %s",
"failed to get paginated tags: %s": "分页查询标签失败: %s",
"failed to get token by key: %s": "按密钥查询令牌失败: %s",
"failed to get token key (tokenId=%d, task=%s): %s": "获取令牌 key 失败 (tokenId=%d, task=%s): %s",
"failed to get user quota: %s": "查询用户额度失败: %s",
"failed to increase token quota: %s": "增加令牌额度失败: %s",
"failed to increase user quota: %s": "增加用户额度失败: %s",
"failed to init token cache: %s": "初始化令牌缓存失败: %s",
"failed to initialize authorization: %s": "初始化权限控制失败: %s",
"failed to initialize database: %s": "初始化数据库失败: %s",
"failed to initialize i18n: %s": "初始化 i18n 失败: %s",
"failed to initialize password encryption: %s": "初始化密码加密失败: %s",
"failed to initialize resources: %s": "初始化资源失败: %s",
"failed to insert redemption: %s": "保存兑换码失败: %s",
"failed to invalidate token cache after hard deleting user %d: %v": "永久删除用户 %d 后清除令牌缓存失败: %v",
"failed to invalidate token cache before batch delete: %s": "批量删除令牌前清除缓存失败: %s",
"failed to invalidate token cache before delete: %s": "删除令牌前清除缓存失败: %s",
"failed to invalidate token cache before status update: %s": "更新令牌状态前清除缓存失败: %s",
"failed to invalidate token cache before update: %s": "更新令牌前清除缓存失败: %s",
"failed to invalidate tokens cache for user %d: %s": "清除用户 %d 的令牌缓存失败: %s",
"failed to invalidate user cache after hard deleting user %d: %v": "永久删除用户 %d 后清除用户缓存失败: %v",
"failed to load custom OAuth providers: %s": "加载自定义 OAuth 提供商失败: %s",
"failed to load tool prices, using hardcoded fallbacks: %s": "加载工具价格失败,将使用硬编码兜底: %s",
"failed to marshal gemini response: %s": "序列化 Gemini 响应失败: %s",
"failed to marshal log other: %s": "序列化日志 other 字段失败: %s",
"failed to marshal other info: channel_id=%d, tag=%s, name=%s, error=%v": "序列化渠道的 other_info 失败: channel_id=%d, tag=%s, name=%s, error=%v",
"failed to marshal setting: %s": "序列化用户设置失败: %s",
"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 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",
"failed to parse CHANNEL_UPDATE_FREQUENCY: %s": "解析 CHANNEL_UPDATE_FREQUENCY 失败: %s",
"failed to parse Redis connection string: %s": "解析 Redis 连接字符串失败: %s",
"failed to parse auto groups for token %d: %v": "解析令牌 %d 的 Auto 分组失败: %v",
"failed to parse oauth key: %s": "解析渠道的 OAuth 凭证失败: %s",
"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 read TTS response body: %v": "读取 TTS 响应体失败: %v",
"failed to record log: %s": "记录日志失败: %s",
"failed to record task billing log: %s": "记录任务计费日志失败: %s",
"failed to record topup log: %s": "记录充值日志失败: %s",
"failed to redeem key %s for user %d: %s": "用户 %[2]d 使用兑换码 %[1]s 兑换失败: %[3]s",
"failed to refresh codex channel credential: %s": "刷新 Codex 渠道凭证失败: %s",
"failed to refresh user group cache after %s for user %d: %v": "刷新用户 %[2]d 的分组缓存失败(操作: %[1]s): %[3]v",
"failed to refund Midjourney token quota, task %s: %s": "退还 Midjourney 令牌额度失败 task %s: %s",
"failed to refund Midjourney user quota, task %s: %s": "退还 Midjourney 用户额度失败 task %s: %s",
"failed to refund the funding source, task %s: %s": "退还资金来源失败 task %s: %s",
"failed to reload authz policy: %s": "重新加载授权策略失败: %s",
"failed to reset codex usage": "重置 Codex 用量失败",
"failed to save backup codes: %s": "保存备用码失败: %s",
"failed to search tokens: %s": "搜索令牌失败: %s",
"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",
"failed to send subscription quota notify to user %d: %s": "向用户 %d 发送订阅额度通知失败: %s",
"failed to serialize VideoUrls: %v": "序列化 VideoUrls 失败: %v",
"failed to start HTTP server: %s": "HTTP 服务启动失败: %s",
"failed to start pprof: %s": "启动pprof失败 %s",
"failed to sync %s credit to user quota cache: %s": "把到账额度同步到用户额度缓存失败(操作: %s): %s",
"failed to sync manual quota adjustment for user %d: %s": "把用户 %d 的手动调额同步到缓存失败: %s",
"failed to synchronously populate user cache: %s": "同步写入用户缓存失败: %s",
"failed to synchronously populate user session cache: %s": "同步写入用户会话缓存失败: %s",
"failed to unmarshal Claude stream event: %s": "解析 Claude 流事件失败: %s",
"failed to unmarshal buffered responses stream event: %s": "解析已缓冲的 Responses 流事件失败: %s",
"failed to unmarshal chat stream response: %s": "解析 Chat 流式响应失败: %s",
"failed to unmarshal header override: channel_id=%d, error=%v": "解析渠道的请求头覆盖失败: channel_id=%d, error=%v",
"failed to unmarshal other info: channel_id=%d, tag=%s, name=%s, error=%v": "解析渠道的 other_info 失败: channel_id=%d, tag=%s, name=%s, error=%v",
"failed to unmarshal param override: channel_id=%d, error=%v": "解析渠道的参数覆盖失败: channel_id=%d, error=%v",
"failed to unmarshal responses stream event: %s": "解析 Responses 流事件失败: %s",
"failed to unmarshal setting: %s": "解析用户设置失败: %s",
"failed to unmarshal setting: channel_id=%d, error=%v": "解析渠道设置失败: channel_id=%d, error=%v",
"failed to unmarshal stream response: %s": "解析流式响应失败: %s",
"failed to unmarshal user: %s": "解析 user 失败: %s",
"failed to unmarshal websocket close event: %v": "解析 WebSocket 关闭事件失败: %v",
"failed to update abilities: channel_id=%d, tag=%s, error=%v": "更新渠道的 abilities 记录失败: channel_id=%d, tag=%s, error=%v",
"failed to update ability status: channel_id=%d, error=%v": "更新渠道的 abilities 状态失败: channel_id=%d, error=%v",
"failed to update balance: channel_id=%d, error=%v": "更新余额失败: channel_id=%d, error=%v",
"failed to update channel status: channel_id=%d, status=%d, error=%v": "更新渠道状态失败: channel_id=%d, status=%d, error=%v",
"failed to update channel used quota: channel_id=%d, delta_quota=%d, error=%v": "更新渠道已用额度失败: channel_id=%d, delta_quota=%d, error=%v",
"failed to update config %s: %s": "更新配置 %s 失败: %s",
"failed to update multi-key status: channel_id=%d, using key not found": "更新多密钥状态失败: channel_id=%d,密钥列表里找不到本次使用的密钥",
"failed to update option map: %s": "加载配置项失败: %s",
"failed to update response time: channel_id=%d, error=%v": "更新响应时间失败: channel_id=%d, error=%v",
"failed to update rotated user session cache: %s": "续期登录后更新会话缓存失败: %s",
"failed to update token status%s": "更新令牌状态失败: %s",
"failed to update user group cache: %s": "更新用户分组缓存失败: %s",
"failed to update user last_login_at: %s": "更新用户最后登录时间失败: %s",
"failed to update user name cache: %s": "更新用户名缓存失败: %s",
"failed to update user setting cache: %s": "更新用户设置缓存失败: %s",
"failed to update user used quota and request count: %s": "更新用户已用额度和请求次数失败: %s",
"failed to update user used quota: %s": "更新用户已用额度失败: %s",
"failed to validate cloned channel: %s": "复制出的渠道未通过校验: %s",
"failed to write TTS response: %v": "写入 TTS 响应失败: %v",
"failed to write back the settled task quota, task %s: %s": "差额结算回写 quota 失败 task %s: %s",
"failed to write stream data: %s": "写入流数据失败: %s",
"failed to write to temp file: %s": "写入临时文件失败: %s",
"final group ratio: %f": "最终分组倍率: %f",
"final group: %s": "最终使用的分组: %s",
"getAndValidateTextRequest failed: %s": "getAndValidateTextRequest 失败: %s",
"get_channel_null: %s": "查询渠道失败: %s",
"group ratio not found: %s": "未找到分组倍率: %s",
"hourly user session issuance exceeded alert threshold: count=%d threshold=%d window_seconds=%d": "近一小时新建的登录会话数超过告警阈值: count=%d threshold=%d window_seconds=%d",
"http error on %s: %s": "请求 %s 出错: %s",
"http transport negotiated: host=%s protocol=%s shards=%d policy=%s negotiated=%s": "HTTP 连接协商结果: host=%s protocol=%s shards=%d policy=%s negotiated=%s",
"http transport select: host=%s protocol=%s shards=%d policy=%s": "HTTP 连接选择: host=%s protocol=%s shards=%d policy=%s",
"http transport: host=%s protocol=%s shard=%d/%d policy=%s negotiated=%s": "HTTP 连接: host=%s protocol=%s shard=%d/%d policy=%s negotiated=%s",
"i18n initialized with languages: %s": "i18n 已初始化,语言: %s",
"image request body: %s": "图片请求体: %s",
"image token input: format=%s, width=%d, height=%d": "计算图片 token: format=%s, width=%d, height=%d",
"increaseQuotaData error: %s": "increaseQuotaData 出错: %s",
"initialize legacy access token deadline: %s": "设置旧访问令牌的停用时间失败: %s",
"initialized the sidebar config for new user %s (role: %d)": "为新用户 %s (角色: %d) 初始化边栏配置",
"insert task error: %s": "保存任务失败: %s",
"invalid SQL_SLOW_THRESHOLD_MS %d (allowed 0-%d, 0 disables slow query log), using default %d": "SQL_SLOW_THRESHOLD_MS=%d 超出范围(可填 0 到 %d,0 表示关闭慢查询日志),已使用默认值 %d",
"invalid access token scopes: %s": "访问令牌的作用域无效: %s",
"invalid channel http transport setting clamped: %s=%q": "渠道的 HTTP 连接设置超出范围,已调整为允许的值: %s=%q",
"invalid image cache token breakdown; using aggregate cache billing": "图片缓存 token 的分项数据无效,按缓存 token 总量计费",
"invalid image stream response": "图片流式响应无效",
"invalid request policy: %s": "请求策略无效: %s",
"invalid response or response body": "上游响应为空或没有响应体",
"invalid task artifact store configuration: %s; using upstream mode": "任务制品存储配置无效: %s,改用 upstream 模式",
"invalid thinking_model_blacklist regex %q: %v": "thinking_model_blacklist 正则 %q 无效: %v",
"invalid thinking_model_blacklist regex %q: pattern is empty": "thinking_model_blacklist 正则 %q 无效: 表达式为空",
"invalid zhipu key: %s": "智谱密钥格式无效: %s",
"json decode failed from %s: %s": "解析 %s 返回的 JSON 失败: %s",
"json marshal failed: %s": "JSON 序列化失败: %s",
"legacy Uptime Kuma options were not migrated: %v": "旧的 Uptime Kuma 配置迁移失败: %v",
"legacy Uptime Kuma options were not migrated: both URL and slug are required": "旧的 Uptime Kuma 配置没有迁移: URL 和 slug 都需要填写",
"legacy option %s was not migrated: %v": "旧配置项 %s 迁移失败: %v",
"legacy proxy URL suffix ignored at runtime: scheme=%s host=%s; update the channel proxy setting": "渠道代理地址带有旧写法的后缀,运行时已忽略: scheme=%s host=%s。请修改渠道的代理设置",
"load advanced custom channel settings error: channel_id=%d, error=%v": "加载高级自定义渠道设置出错: channel_id=%d, error=%v",
"loadFromURL: decoding image config": "loadFromURL: 解析图片头信息",
"loadFromURL: initiating download": "loadFromURL: 开始下载",
"loadFromURL: reading response body": "loadFromURL: 读取响应体",
"manual topup": "管理员补单",
"memory cache enabled": "已启用内存缓存",
"midjourney response body: %s": "Midjourney 响应体: %s",
"model %s is free, skipping pre-consume": "模型 %s 免费,跳过预扣费",
"model price not found: %s": "未找到模型价格: %s",
"model_price_helper result: %s": "model_price_helper 结果: %s",
"model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s": "model_price_helper_tiered 结果: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s",
"models.dev parse failed from %s: %s": "解析 %s 返回的 models.dev 数据失败: %s",
"no such user %s": "用户 %s 不存在",
"no user exists, create a root user for you: username is root, password is 123456": "系统里还没有用户,已自动创建 root 账号:用户名 root,密码 123456",
"non-200 from %s: %s": "%s 返回了非 200 状态: %s",
"ollama stream json decode error: %s line=%s": "Ollama 流式响应 JSON 解析失败: %s line=%s",
"ollama stream scan error: %s": "Ollama 流式响应读取出错: %s",
"openrouter enterprise response success=false, data: %s": "OpenRouter Enterprise 响应 success=false, data: %s",
"options key %q had conflicting values; keeping the last row": "options 表的键 %q 有多行且值不同,保留最后一行的值",
"options table has duplicate pricing keys [%s]; the table is missing a primary key": "options 表缺少主键,定价配置出现重复的键 [%s]",
"panic detected: %v": "发生 panic: %v",
"panic in gopool.RelayPool: %v": "gopool.RelayPool 中发生 panic: %v",
"panic recovered in plugin route": "插件路由发生 panic,已恢复",
"passkey verification rejected: code=%s reason=%s rp_id=%q": "通行密钥验证未通过: code=%s reason=%s rp_id=%q",
"payment compliance confirmed user_id=%d ip=%s terms_version=%s confirmed_at=%d": "用户已确认支付合规条款 user_id=%d ip=%s terms_version=%s confirmed_at=%d",
"persist task background flag failed: %s": "保存任务的后台运行标记失败: %s",
"ping data error: %s": "发送 ping 数据出错: %s",
"ping data sent": "ping 数据已发送",
"ping goroutine exited": "ping goroutine 已退出",
"ping goroutine max duration reached": "ping goroutine 运行达到最长时限,已停止",
"ping goroutine panic: %v": "ping goroutine 发生 panic: %v",
"ping interval seconds: %d": "ping 间隔: %d 秒",
"pinned task plugin does not support the requested protocol form": "选定的任务插件不支持本次请求的调用方式(流式、后台或同步)",
"pprof enabled": "已启用 pprof",
"pre-consumed quota matches the actual quota, no adjustment: %s (per-request billing)": "预扣费与实际消耗一致,无需调整:%s(按次计费)",
"quota saturation on consume log: op=%s kind=%s original=%g clamped=%d user=%d model=%s": "消费日志: 额度超出上限,已按上限记录: op=%s kind=%s original=%g clamped=%d user=%d model=%s",
"rate limit check failed (key=%s): %v": "限流检查失败 (key=%s): %v",
"rate limit check failed (mark=%s): %v": "限流检查失败 (mark=%s): %v",
"read response failed from %s: %s": "读取 %s 的响应失败: %s",
"ready in %d ms": "启动完成,耗时 %d ms",
"realtime error: %s": "Realtime 出错: %s",
"realtime streaming consume quota success, quota: %d": "Realtime 扣费成功,额度: %d",
"realtime streaming localUsage: %v": "Realtime 流式 localUsage: %v",
"realtime streaming sumUsage: %v": "Realtime 流式 sumUsage: %v",
"rebuild task alias view: %s": "重建任务模型别名表失败: %s",
"rebuilt options table with primary key from %d rows into %d keys; previous rows kept in %s": "已重建 options 表并加上主键,%d 行合并成 %d 个键,重建前的数据保存在 %s",
"received [DONE], stopping scanner": "收到 [DONE],停止读取流",
"received signal: %v, shutting down...": "收到信号 %v,正在关闭服务...",
"record consume log: userId=%d, params=%s": "记录消费日志: userId=%d, params=%s",
"record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s": "记录错误日志: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s",
"redemption": "兑换码兑换",
"redemption failed: %s": "兑换失败: %s",
"refund succeeded but clearing the task quota failed, task %s: %s": "退款成功但清除 task quota 失败 task %s: %s",
"refunding the difference after pre-consume: %s (actual %s, pre-consumed %s)": "预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)",
"relay error (channel #%d, status code %d): %s": "转发请求出错 (渠道 #%d,状态码 %d): %s",
"relay error: %s": "转发请求出错: %s",
"relay max idle conns per host: %d": "转发连接池每个主机的最大空闲连接数: %d",
"relay max idle conns: %d": "转发连接池的最大空闲连接数: %d",
"relay timeout seconds: %d": "转发超时: %d 秒",
"reranker response body: %s": "重排序响应体: %s",
"reserve adjusted task billing error: %s": "按调整后的任务费用补扣预扣费失败: %s",
"responses stream failed after output was delivered, billing the delivered output: %s": "Responses 流中途失败,已输出的内容照常计费: %s",
"responses websocket relay error: %s": "Responses WebSocket 转发出错: %s",
"retry: %s": "重试:%s",
"running in debug mode": "调试模式已开启",
"saved dashboard data, %d records": "保存数据看板数据成功,共保存%d条数据",
"scanner error: %s": "读取流出错: %s",
"scanner goroutine exited": "scanner goroutine 已退出",
"scanner goroutine panic: %v": "scanner goroutine 发生 panic: %v",
"send final Gemini stream response failed: %s": "发送 Gemini 流式响应的最后一段失败: %s",
"send final response failed: %s": "发送最后一段响应失败: %s",
"send_stream_response_failed: %s": "发送流式响应失败: %s",
"server exited": "服务已退出",
"server forced to shutdown: %v": "服务未能正常关闭,已强制退出: %v",
"settle task billing error: %s": "任务计费结算出错: %s",
"setup log is already working": "日志正在初始化,跳过本次调用",
"skip password reset email for %s: %s": "未向 %s 发送密码重置邮件: %s",
"skipped %d options rows with empty keys while rebuilding the primary key": "重建主键时跳过了 %d 行键为空的 options 记录",
"stacktrace from panic: %s": "panic 堆栈: %s",
"start pyroscope error : %v": "启动 pyroscope 出错: %v",
"stream ended: %s": "流式响应结束: %s",
"stream ended: %s, received=%d": "流式响应结束: %s, received=%d",
"stream event error: %v %v": "流事件报错: %v %v",
"stream scanner data: %s": "收到流数据: %s",
"streaming timeout seconds: %d": "流式超时: %d 秒",
"stripe topup": "Stripe 充值",
"subscription balance purchase": "余额购买订阅",
"subscription expiration": "订阅到期",
"subscription expire task failed: %v": "处理过期订阅失败: %v",
"subscription maintenance: reset_count=%d, expired_count=%d": "订阅维护: reset_count=%d, expired_count=%d",
"subscription payment completion": "订阅支付完成",
"subscription quota reset task failed: %v": "重置订阅额度失败: %v",
"subscription quota reset task started: tick=%s": "订阅额度重置任务已启动: tick=%s",
"sweepTimedOutTasks CAS update error for task %s: %v": "sweepTimedOutTasks: 更新任务 %s 的状态出错: %v",
"sweepTimedOutTasks: task %s already transitioned, skip": "sweepTimedOutTasks: 任务 %s 的状态已由其他进程更新,跳过",
"sweepTimedOutTasks: timed out %d tasks": "sweepTimedOutTasks: 已把 %d 个超时任务标记为失败",
"sync frequency: %d seconds": "同步间隔: %d 秒",
"syncing channels from database": "正在从数据库同步渠道",
"syncing options from database": "正在从数据库同步配置项",
"system instance report failed: %v": "上报本实例信息失败: %v",
"system is already initialized at: %s": "系统已于 %s 完成初始化",
"system is not initialized and no root user exists": "系统尚未初始化,还没有 root 用户",
"system is not initialized, but root user exists": "系统尚未初始化,但已有 root 用户",
"system task %s failed to persist result: %v": "系统任务 %s 保存结果失败: %v",
"system task %s failed to save failure state: %v": "系统任务 %s 保存失败状态时出错: %v",
"system task %s failed: %v": "系统任务 %s 失败: %v",
"system task %s lock lost": "系统任务 %s 的执行锁已失效",
"system task %s update failed: %v": "系统任务 %s 更新失败: %v",
"system task claim failed: %v": "领取系统任务失败: %v",
"system task runner query failed: %v": "查询待执行的系统任务失败: %v",
"system task runner started: runner=%s idle_interval=%s": "系统任务执行器已启动: runner=%s idle_interval=%s",
"system task scheduler active lookup failed: type=%s err=%v": "系统任务调度: 查询进行中的任务失败: type=%s err=%v",
"system task scheduler create failed: type=%s err=%v": "系统任务调度: 创建任务失败: type=%s err=%v",
"system task scheduler query failed: %v": "系统任务调度: 查询失败: %v",
"system task stale lock cleanup failed: %v": "清理系统任务的过期执行锁失败: %v",
"task %s expression settlement failed, keeping the pre-consumed quota: %v": "任务 %s 表达式结算失败,保留预扣额度: %v",
"task %s expression settlement quota was clamped: %+v": "任务 %s 表达式结算额度发生饱和: %+v",
"task %s is billed per request, skipping difference settlement": "任务 %s 按次计费,跳过差额结算",
"task %s poll %s (failures=%d, http=%d): %s": "任务 %s 轮询结果异常: %s (failures=%d, http=%d): %s",
"task %s pre-consumed quota matches the actual quota (%s, %s)": "任务 %s 预扣费准确(%s,%s)",
"task %s settles the difference: delta=%s (actual %s, pre-consumed %s, %s)": "任务 %s 差额结算:delta=%s(实际:%s,预扣:%s,%s)",
"task alias mapping cycle dropped: channel=%d key=%q": "渠道的模型映射成环,这个任务模型别名已忽略: channel=%d key=%q",
"task alias view: channel %d model_mapping: %s": "解析渠道 %d 的 model_mapping 失败,任务模型别名未包含该渠道: %s",
"task artifact S3 storage is not implemented; using upstream mode": "任务制品 S3 存储尚未实现,改用 upstream 模式",
"task billing other ratio key rejected: %s": "任务计费: 倍率键 %s 不能写入日志,已跳过",
"task immediate usage settlement failed; retaining reserved quota: %v": "任务提交即完成,按用量结算失败,保留预扣额度: %v",
"task model alias %q dropped: maps to multiple plugins %v": "任务模型别名 %q 对应多个插件 %v,已忽略",
"task plugin %s completion context failed; retaining reserved quota: %v": "任务插件 %s: 构建完成结算的上下文失败,保留预扣额度: %v",
"task plugin %s completion usage failed; retaining reserved quota: %v": "任务插件 %s: 提取完成用量失败,保留预扣额度: %v",
"task plugin %s completion usage rejected; retaining reserved quota: %v": "任务插件 %s: 返回的完成用量无效,保留预扣额度: %v",
"task plugin %s rejected invalid %s billing facts": "任务插件 %s 的 %s 钩子返回了无效的计费数据,已忽略",
"task plugin %s rejected invalid or oversized poll state": "任务插件 %s 返回的轮询状态无效或过大,未保存",
"task plugin %s rejected oversized poll state (%d bytes)": "任务插件 %s 返回的轮询状态过大(%d 字节),未保存",
"task plugin %s rejected oversized submit state (%d bytes)": "任务插件 %s 返回的提交状态过大(%d 字节),未保存",
"task plugin native submit presenter failed: %s": "任务插件提交响应: 渲染钩子执行失败: %s",
"task plugin route %s %s declares retainResult: false but returned an asynchronous result; retaining task %s": "任务插件路由 %s %s 声明了 retainResult: false,但返回的是异步结果,任务 %s 仍会保存",
"task poll auth failure channel_id=%d http=%d": "任务轮询被上游拒绝(鉴权失败) channel_id=%d http=%d",
"task poll auth failure channel_id=%d task=%s http=%d": "任务轮询被上游拒绝(鉴权失败) channel_id=%d task=%s http=%d",
"task progress polling finished": "任务进度轮询完成",
"task progress polling started": "任务进度轮询开始",
"task protocol database observation failed": "任务协议: 读取任务状态失败",
"task protocol database observation overloaded; plugin=%s task=%s": "任务协议: 读取任务状态超时,数据库繁忙 plugin=%s task=%s",
"task protocol failure event failed: %s": "任务协议: 生成失败事件出错: %s",
"task protocol failure response failed: %s": "任务协议: 生成失败响应出错: %s",
"task protocol final hook failed": "任务协议: 插件的最终响应钩子执行失败",
"task protocol final hook overloaded; plugin=%s task=%s": "任务协议: 插件运行时繁忙,最终响应钩子等待超时 plugin=%s task=%s",
"task protocol image download failed; returning the upstream URL instead of b64_json: %s": "任务协议: 图片下载失败,b64_json 改用上游 URL 返回: %s",
"task protocol image poll failed for task %s: %s": "任务协议: 轮询图片任务 %s 失败: %s",
"task protocol image render hook failed: %s": "任务协议: 插件的图片渲染钩子执行失败: %s",
"task protocol image render hook must return an object with a data array": "任务协议: 插件的图片渲染钩子需要返回带 data 数组的对象",
"task protocol render hook failed": "任务协议: 插件的渲染钩子执行失败",
"task protocol render hook overloaded; plugin=%s task=%s": "任务协议: 插件运行时繁忙,渲染钩子等待超时 plugin=%s task=%s",
"task protocol render hook slow; plugin=%s task=%s elapsed_ms=%d": "任务协议: 插件的渲染钩子执行过慢 plugin=%s task=%s elapsed_ms=%d",
"task protocol render result invalid: %s": "任务协议: 插件渲染钩子返回的结果无效: %s",
"task protocol retrieve lookup failed": "任务协议: 查询任务失败",
"task protocol retrieve render failed": "任务协议: 生成查询响应失败",
"task protocol state transition failed: %s": "任务协议: 状态切换失败: %s",
"task protocol submission returned an invalid durable outcome": "任务协议: 提交后没有得到有效的任务记录",
"task protocol terminal failure response failed: %s": "任务协议: 生成任务失败的最终响应出错: %s",
"task protocol timeout event failed: %s": "任务协议: 生成超时事件出错: %s",
"task protocol timeout response failed: %s": "任务协议: 生成超时响应出错: %s",
"testing channel #%d, response: \n%s": "测试渠道 #%d,响应: \n%s",
"testing channel %d with model %s , info %+v ": "测试渠道 %d,模型 %s,信息 %+v ",
"text request body: %s": "文本请求体: %s",
"token quota cache reserve unavailable, falling back to database: %s": "无法在缓存中预扣令牌额度,改为直接扣数据库: %s",
"tool surcharge call count clamped: tool=%s count=%d": "工具调用次数超出计费上限,按上限计费: tool=%s count=%d",
"tool surcharge call count overflow for %s": "工具 %s 的计费调用次数溢出",
"topup failed: %s": "充值失败: %s",
"topup group ratio not found: %s": "未找到充值分组倍率: %s",
"total tokens is 0, cannot consume quota, userId %d, channelId %d, tokenId %d, model %s, pre-consumed quota %d": "token 总数为 0,无法扣费,userId %d, channelId %d, tokenId %d, model %s,预扣额度 %d",
"try to decode image config with limit: %d": "读取前 %d 字节来解析图片头信息",
"unexpected content-type from %s: %s": "%s 返回的 content-type 不符合预期: %s",
"unfinished tasks found: %v": "检测到未完成的任务数有: %v",
"union is nil or unknown type": "union 为 nil 或类型未知",
"unknown tag: %v": "未知 tag: %v",
"unrecognized data format from %s: %s": "无法识别 %s 返回的数据格式: %s",
"updateVideoSingleTask parsed as new api response format: %+v": "updateVideoSingleTask 按 New API 响应格式解析: %+v",
"updateVideoSingleTask response: %s": "updateVideoSingleTask 响应: %s",
"updating all channels": "开始更新所有渠道的余额",
"updating dashboard data...": "正在更新数据看板数据...",
"upstream model update check failed: channel_id=%d channel_name=%s err=%v": "检查渠道的上游模型变化失败: channel_id=%d channel_name=%s err=%v",
"upstream model update notification skipped in 24h window: changed_channels=%d failed_channels=%d": "24 小时内已发过相同内容的上游模型更新通知,本次不发送: changed_channels=%d failed_channels=%d",
"upstream model update notifications sent: %d": "已发送 %d 条上游模型更新通知",
"upstream model update task done: checked_channels=%d changed_channels=%d detected_add_models=%d detected_remove_models=%d failed_channels=%d auto_added_models=%d": "上游模型更新检查完成: checked_channels=%d changed_channels=%d detected_add_models=%d detected_remove_models=%d failed_channels=%d auto_added_models=%d",
"upstream model update task query failed: %v": "上游模型更新检查: 查询渠道失败: %v",
"upstream response body: %s": "上游响应体: %s",
"usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d": "从倒数第二条 SSE 数据中取得用量: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
"user %d has enough quota, trusted without pre-consume (funding=%s)": "用户 %d 额度充足, 信任且不需要预扣费 (funding=%s)",
"user %d has no bark url, skip sending bark": "用户 %d 没有填写 Bark 推送地址,未发送 Bark 通知",
"user %d has no email, skip sending email": "用户 %d 没有绑定邮箱,未发送邮件通知",
"user %d has no gotify url or token, skip sending gotify": "用户 %d 没有填写 Gotify 地址或令牌,未发送 Gotify 通知",
"user %d has no webhook url, skip sending webhook": "用户 %d 没有填写 Webhook 地址,未发送 Webhook 通知",
"user %d needs pre-consume %s (funding=%s)": "用户 %d 需要预扣费 %s (funding=%s)",
"user %d request failed, refunding the pre-consumed quota (token_quota=%s, funding=%s)": "用户 %d 请求失败, 返还预扣费(token_quota=%s, funding=%s)",
"user %d | %s": "用户 %d | %s",
"user quota cache reserve unavailable, falling back to database: %s": "无法在缓存中预扣用户额度,改为直接扣数据库: %s",
"using ClickHouse as log database": "日志数据库使用 ClickHouse",
"using MySQL as database": "数据库使用 MySQL",
"using PostgreSQL as database": "数据库使用 PostgreSQL",
"waffo pancake topup": "Waffo Pancake 充值",
"waffo pancake topup failed: %s": "Waffo Pancake 充值失败: %s",
"waffo topup": "Waffo 充值",
"waffo topup failed: %s": "Waffo 充值失败: %s",
"websocket connection is nil": "WebSocket 连接为空",
"zhipu_image_empty_b64": "智谱图片接口返回的 b64 为空",
"zhipu_image_get_b64_failed: %s": "智谱图片转 b64 失败: %s",
"zhipu_image_missing_url": "智谱图片接口没有返回 URL",
"¥%.6f quota": "¥%.6f 额度",
"$%.6f quota": "$%.6f 额度"
}
+50
View File
@@ -0,0 +1,50 @@
package common
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
// Message is text shown in the web console. Key is the English source text and
// also its key in web/src/i18n/locales; Params fill its {{name}} placeholders.
// The web console translates Key with Params, so the backend only renders the
// English text, for API callers and log fallbacks. Params carry display-ready
// values: format quota with logger.FormatQuota before passing it.
type Message struct {
Key string `json:"key"`
Params map[string]any `json:"params,omitempty"`
}
func NewMessage(key string, params ...map[string]any) *Message {
message := &Message{Key: key}
if len(params) > 0 {
message.Params = params[0]
}
return message
}
// Error renders the English text, so a Message can be returned as an error and
// reach the web console through ApiError.
func (m *Message) Error() string {
if len(m.Params) == 0 {
return m.Key
}
// One pass over the key, so a value that contains "{{name}}" stays as is.
pairs := make([]string, 0, len(m.Params)*2)
for name, value := range m.Params {
pairs = append(pairs, "{{"+name+"}}", fmt.Sprint(value))
}
return strings.NewReplacer(pairs...).Replace(m.Key)
}
// Fields returns the response fields the web console translates. Callers put
// the English text in the field their response shape already uses.
func (m *Message) Fields() gin.H {
fields := gin.H{"message_key": m.Key}
if len(m.Params) > 0 {
fields["message_params"] = m.Params
}
return fields
}
-20
View File
@@ -2,9 +2,6 @@ package common
import "strings" import "strings"
// ZImagePromptExtendMultiplier is the legacy Ali image request surcharge.
const ZImagePromptExtendMultiplier = 2
var ( var (
// OpenAIResponseOnlyModels is a list of models that are only available for OpenAI responses. // OpenAIResponseOnlyModels is a list of models that are only available for OpenAI responses.
OpenAIResponseOnlyModels = []string{ OpenAIResponseOnlyModels = []string{
@@ -33,13 +30,6 @@ var (
"flux-", "flux-",
"flux.1-", "flux.1-",
} }
OpenAITextModels = []string{
"gpt-",
"o1",
"o3",
"o4",
"chatgpt",
}
) )
func IsOpenAIResponseOnlyModel(modelName string) bool { func IsOpenAIResponseOnlyModel(modelName string) bool {
@@ -66,13 +56,3 @@ func IsImageGenerationModel(modelName string) bool {
} }
return false return false
} }
func IsOpenAITextModel(modelName string) bool {
modelName = strings.ToLower(modelName)
for _, m := range OpenAITextModels {
if strings.Contains(modelName, m) {
return true
}
}
return false
}
+10
View File
@@ -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"))
}
+4 -4
View File
@@ -17,23 +17,23 @@ func Monitor() {
panic(err) panic(err)
} }
if percent[0] > 80 { if percent[0] > 80 {
fmt.Println("cpu usage too high") fmt.Println(LogText("cpu usage too high"))
// write pprof file // write pprof file
if _, err := os.Stat("./pprof"); os.IsNotExist(err) { if _, err := os.Stat("./pprof"); os.IsNotExist(err) {
err := os.Mkdir("./pprof", os.ModePerm) err := os.Mkdir("./pprof", os.ModePerm)
if err != nil { if err != nil {
SysLog("创建pprof文件夹失败 " + err.Error()) SysLog(LogText("failed to create the pprof directory: %s", err.Error()))
continue continue
} }
} }
f, err := os.Create("./pprof/" + fmt.Sprintf("cpu-%s.pprof", time.Now().Format("20060102150405"))) f, err := os.Create("./pprof/" + fmt.Sprintf("cpu-%s.pprof", time.Now().Format("20060102150405")))
if err != nil { if err != nil {
SysLog("创建pprof文件失败 " + err.Error()) SysLog(LogText("failed to create the pprof file: %s", err.Error()))
continue continue
} }
err = pprof.StartCPUProfile(f) err = pprof.StartCPUProfile(f)
if err != nil { if err != nil {
SysLog("启动pprof失败 " + err.Error()) SysLog(LogText("failed to start pprof: %s", err.Error()))
continue continue
} }
time.Sleep(10 * time.Second) // profile for 30 seconds time.Sleep(10 * time.Second) // profile for 30 seconds
-5
View File
@@ -1,5 +0,0 @@
package common
func GetTrustQuota() int {
return int(10 * QuotaPerUnit)
}
+17 -17
View File
@@ -24,17 +24,17 @@ func RedisKeyCacheSeconds() int {
func InitRedisClient() (err error) { func InitRedisClient() (err error) {
if os.Getenv("REDIS_CONN_STRING") == "" { if os.Getenv("REDIS_CONN_STRING") == "" {
RedisEnabled = false RedisEnabled = false
SysLog("REDIS_CONN_STRING not set, Redis is not enabled") SysLog(LogText("REDIS_CONN_STRING not set, Redis is not enabled"))
return nil return nil
} }
if os.Getenv("SYNC_FREQUENCY") == "" { if os.Getenv("SYNC_FREQUENCY") == "" {
SysLog("SYNC_FREQUENCY not set, use default value 60") SysLog(LogText("SYNC_FREQUENCY not set, use default value 60"))
SyncFrequency = 60 SyncFrequency = 60
} }
SysLog("Redis is enabled") SysLog(LogText("Redis is enabled"))
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING")) opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
if err != nil { if err != nil {
FatalLog("failed to parse Redis connection string: " + err.Error()) FatalLog(LogText("failed to parse Redis connection string: %s", err.Error()))
} }
opt.PoolSize = GetEnvOrDefault("REDIS_POOL_SIZE", 10) opt.PoolSize = GetEnvOrDefault("REDIS_POOL_SIZE", 10)
RDB = redis.NewClient(opt) RDB = redis.NewClient(opt)
@@ -44,11 +44,11 @@ func InitRedisClient() (err error) {
_, err = RDB.Ping(ctx).Result() _, err = RDB.Ping(ctx).Result()
if err != nil { if err != nil {
FatalLog("Redis ping test failed: " + err.Error()) FatalLog(LogText("Redis ping test failed: %s", err.Error()))
} }
if DebugEnabled { if DebugEnabled {
SysLog(fmt.Sprintf("Redis connected to %s", opt.Addr)) SysLog(LogText("Redis connected to %s", opt.Addr))
SysLog(fmt.Sprintf("Redis database: %d", opt.DB)) SysLog(LogText("Redis database: %d", opt.DB))
} }
return err return err
} }
@@ -56,14 +56,14 @@ func InitRedisClient() (err error) {
func ParseRedisOption() *redis.Options { func ParseRedisOption() *redis.Options {
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING")) opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
if err != nil { if err != nil {
FatalLog("failed to parse Redis connection string: " + err.Error()) FatalLog(LogText("failed to parse Redis connection string: %s", err.Error()))
} }
return opt return opt
} }
func RedisSet(key string, value string, expiration time.Duration) error { func RedisSet(key string, value string, expiration time.Duration) error {
if DebugEnabled { if DebugEnabled {
SysLog(fmt.Sprintf("Redis SET: key=%s, value=%s, expiration=%v", key, value, expiration)) SysLog(LogText("Redis SET: key=%s, value=%s, expiration=%v", key, value, expiration))
} }
ctx := context.Background() ctx := context.Background()
return RDB.Set(ctx, key, value, expiration).Err() return RDB.Set(ctx, key, value, expiration).Err()
@@ -71,7 +71,7 @@ func RedisSet(key string, value string, expiration time.Duration) error {
func RedisGet(key string) (string, error) { func RedisGet(key string) (string, error) {
if DebugEnabled { if DebugEnabled {
SysLog(fmt.Sprintf("Redis GET: key=%s", key)) SysLog(LogText("Redis GET: key=%s", key))
} }
ctx := context.Background() ctx := context.Background()
val, err := RDB.Get(ctx, key).Result() val, err := RDB.Get(ctx, key).Result()
@@ -90,7 +90,7 @@ func RedisGet(key string) (string, error) {
func RedisDel(key string) error { func RedisDel(key string) error {
if DebugEnabled { if DebugEnabled {
SysLog(fmt.Sprintf("Redis DEL: key=%s", key)) SysLog(LogText("Redis DEL: key=%s", key))
} }
ctx := context.Background() ctx := context.Background()
return RDB.Del(ctx, key).Err() return RDB.Del(ctx, key).Err()
@@ -98,7 +98,7 @@ func RedisDel(key string) error {
func RedisDelKey(key string) error { func RedisDelKey(key string) error {
if DebugEnabled { if DebugEnabled {
SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key)) SysLog(LogText("Redis DEL Key: key=%s", key))
} }
ctx := context.Background() ctx := context.Background()
return RDB.Del(ctx, key).Err() return RDB.Del(ctx, key).Err()
@@ -106,7 +106,7 @@ func RedisDelKey(key string) error {
func RedisHSetObj(key string, obj any, expiration time.Duration) error { func RedisHSetObj(key string, obj any, expiration time.Duration) error {
if DebugEnabled { if DebugEnabled {
SysLog(fmt.Sprintf("Redis HSET: key=%s, obj=%+v, expiration=%v", key, obj, expiration)) SysLog(LogText("Redis HSET: key=%s, obj=%+v, expiration=%v", key, obj, expiration))
} }
ctx := context.Background() ctx := context.Background()
@@ -160,7 +160,7 @@ func RedisHSetObj(key string, obj any, expiration time.Duration) error {
func RedisHGetObj(key string, obj any) error { func RedisHGetObj(key string, obj any) error {
if DebugEnabled { if DebugEnabled {
SysLog(fmt.Sprintf("Redis HGETALL: key=%s", key)) SysLog(LogText("Redis HGETALL: key=%s", key))
} }
ctx := context.Background() ctx := context.Background()
@@ -241,7 +241,7 @@ func RedisHGetObj(key string, obj any) error {
// RedisIncr Add this function to handle atomic increments // RedisIncr Add this function to handle atomic increments
func RedisIncr(key string, delta int64) error { func RedisIncr(key string, delta int64) error {
if DebugEnabled { if DebugEnabled {
SysLog(fmt.Sprintf("Redis INCR: key=%s, delta=%d", key, delta)) SysLog(LogText("Redis INCR: key=%s, delta=%d", key, delta))
} }
// 检查键的剩余生存时间 // 检查键的剩余生存时间
ttlCmd := RDB.TTL(context.Background(), key) ttlCmd := RDB.TTL(context.Background(), key)
@@ -274,7 +274,7 @@ func RedisIncr(key string, delta int64) error {
func RedisHIncrBy(key, field string, delta int64) error { func RedisHIncrBy(key, field string, delta int64) error {
if DebugEnabled { if DebugEnabled {
SysLog(fmt.Sprintf("Redis HINCRBY: key=%s, field=%s, delta=%d", key, field, delta)) SysLog(LogText("Redis HINCRBY: key=%s, field=%s, delta=%d", key, field, delta))
} }
ttlCmd := RDB.TTL(context.Background(), key) ttlCmd := RDB.TTL(context.Background(), key)
ttl, err := ttlCmd.Result() ttl, err := ttlCmd.Result()
@@ -301,7 +301,7 @@ func RedisHIncrBy(key, field string, delta int64) error {
func RedisHSetField(key, field string, value any) error { func RedisHSetField(key, field string, value any) error {
if DebugEnabled { if DebugEnabled {
SysLog(fmt.Sprintf("Redis HSET field: key=%s, field=%s, value=%v", key, field, value)) SysLog(LogText("Redis HSET field: key=%s, field=%s, value=%v", key, field, value))
} }
ttlCmd := RDB.TTL(context.Background(), key) ttlCmd := RDB.TTL(context.Background(), key)
ttl, err := ttlCmd.Result() ttl, err := ttlCmd.Result()
+2 -2
View File
@@ -135,6 +135,6 @@ func MaskEmail(email string) string {
// MaskSensitiveInfo moved to the conversion kit (kitutil) because the types // MaskSensitiveInfo moved to the conversion kit (kitutil) because the types
// package error formatting depends on it; host callers keep this name. // package error formatting depends on it; host callers keep this name.
func MaskSensitiveInfo(str string) string { func MaskSensitiveInfo(str string, upstreamURLs ...string) string {
return kitutil.MaskSensitiveInfo(str) return kitutil.MaskSensitiveInfo(str, upstreamURLs...)
} }
+4 -4
View File
@@ -50,20 +50,20 @@ func LogStartupSuccess(startTime time.Time, port string) {
// Warn when the local HTTP compatibility mode disables cookie transport // Warn when the local HTTP compatibility mode disables cookie transport
// security and refresh/logout Origin validation. // security and refresh/logout Origin validation.
fmt.Fprintf(gin.DefaultWriter, "\n") fmt.Fprintf(gin.DefaultWriter, "\n")
fmt.Fprintf(gin.DefaultWriter, " \033[33mWarning: Refresh cookie is not secure and refresh/logout Origin validation is disabled. Please set SESSION_COOKIE_SECURE=true in production.\033[0m\n") fmt.Fprintf(gin.DefaultWriter, " \033[33m%s\033[0m\n", LogText("Warning: Refresh cookie is not secure and refresh/logout Origin validation is disabled. Please set SESSION_COOKIE_SECURE=true in production."))
fmt.Fprintf(gin.DefaultWriter, "\n") fmt.Fprintf(gin.DefaultWriter, "\n")
} }
fmt.Fprintf(gin.DefaultWriter, "\n") fmt.Fprintf(gin.DefaultWriter, "\n")
fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs) fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m %s\n", SystemName, Version, LogText("ready in %d ms", durationMs))
fmt.Fprintf(gin.DefaultWriter, "\n") fmt.Fprintf(gin.DefaultWriter, "\n")
if !IsRunningInContainer() { if !IsRunningInContainer() {
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mLocal:\033[0m http://localhost:%s/\n", port) fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1m%-8s\033[0m http://localhost:%s/\n", LogText("Local:"), port)
} }
for _, ip := range networkIps { for _, ip := range networkIps {
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port) fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1m%-8s\033[0m http://%s:%s/\n", LogText("Network:"), ip, port)
} }
fmt.Fprintf(gin.DefaultWriter, "\n") fmt.Fprintf(gin.DefaultWriter, "\n")
+96 -17
View File
@@ -1,6 +1,7 @@
package common package common
import ( import (
"fmt"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -25,10 +26,36 @@ type SystemStatus struct {
CPUUsage float64 CPUUsage float64
MemoryUsage float64 MemoryUsage float64
DiskUsage 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 var latestSystemStatus atomic.Value
const (
systemMonitorInterval = 5 * time.Second
// 连续 3 次采样(约 15 秒)仍高于阈值才判定过载,
// 任一采样回落到阈值以下即恢复,避免瞬时尖峰打满重试。
overloadConsecutiveSamples = 3
// 采样超过该时长未刷新(监控未运行或被阻塞)时,哨兵不据此熔断。
systemStatusMaxAge = 30 * time.Second
)
func init() { func init() {
latestSystemStatus.Store(SystemStatus{}) latestSystemStatus.Store(SystemStatus{})
} }
@@ -36,46 +63,98 @@ func init() {
// StartSystemMonitor 启动系统监控 // StartSystemMonitor 启动系统监控
func StartSystemMonitor() { func StartSystemMonitor() {
go func() { go func() {
usage := newContainerUsage()
var detector overloadDetector
for { for {
config := GetPerformanceMonitorConfig() config := GetPerformanceMonitorConfig()
if !config.Enabled { if !config.Enabled {
time.Sleep(30 * time.Second) detector = overloadDetector{}
latestSystemStatus.Store(SystemStatus{SampledAt: time.Now()})
time.Sleep(systemMonitorInterval)
continue continue
} }
updateSystemStatus() status := collectSystemStatus(usage)
time.Sleep(5 * time.Second) detector.mark(&status, config)
latestSystemStatus.Store(status)
time.Sleep(systemMonitorInterval)
} }
}() }()
} }
func updateSystemStatus() { // overloadDetector 为每项资源累计连续超阈值的采样次数:连续 overloadConsecutiveSamples 次
var status SystemStatus // 才标记过载,任一次回落立即清零并重新累计。
type overloadDetector struct {
cpuSamples int
memorySamples int
diskSamples int
}
// CPU func (d *overloadDetector) mark(status *SystemStatus, config PerformanceMonitorConfig) {
// 注意:cpu.Percent(0, false) 返回自上次调用以来的 CPU 使用率 status.CPUOverloaded = countOverloadSample(&d.cpuSamples, exceedsThreshold(status.CPUUsage, config.CPUThreshold))
// 如果是第一次调用,可能会返回错误或不准确的值,但在循环中会逐渐正常 status.MemoryOverloaded = countOverloadSample(&d.memorySamples, exceedsThreshold(status.MemoryUsage, config.MemoryThreshold))
percents, err := cpu.Percent(0, false) status.DiskOverloaded = countOverloadSample(&d.diskSamples, exceedsThreshold(status.DiskUsage, config.DiskThreshold))
if err == nil && len(percents) > 0 { }
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] status.CPUUsage = percents[0]
} }
if containerMemory, ok := usage.memoryUsage(); ok {
// Memory status.MemoryUsage = containerMemory
memInfo, err := mem.VirtualMemory() } else if memInfo, err := mem.VirtualMemory(); err == nil {
if err == nil {
status.MemoryUsage = memInfo.UsedPercent status.MemoryUsage = memInfo.UsedPercent
} }
// Disk
diskInfo := GetDiskSpaceInfo() diskInfo := GetDiskSpaceInfo()
if diskInfo.Total > 0 { if diskInfo.Total > 0 {
status.DiskUsage = diskInfo.UsedPercent status.DiskUsage = diskInfo.UsedPercent
} }
return status
latestSystemStatus.Store(status)
} }
// GetSystemStatus 获取当前系统状态 // GetSystemStatus 获取当前系统状态
func GetSystemStatus() SystemStatus { func GetSystemStatus() SystemStatus {
return latestSystemStatus.Load().(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
}
+280
View File
@@ -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())
})
}
}
+2 -2
View File
@@ -17,7 +17,7 @@ func TopupGroupRatio2JSONString() string {
defer topupGroupRatioMutex.RUnlock() defer topupGroupRatioMutex.RUnlock()
jsonBytes, err := json.Marshal(topupGroupRatio) jsonBytes, err := json.Marshal(topupGroupRatio)
if err != nil { if err != nil {
SysError("error marshalling topup group ratio: " + err.Error()) SysError(LogText("error marshalling topup group ratio: %s", err.Error()))
} }
return string(jsonBytes) return string(jsonBytes)
} }
@@ -34,7 +34,7 @@ func GetTopupGroupRatio(name string) float64 {
defer topupGroupRatioMutex.RUnlock() defer topupGroupRatioMutex.RUnlock()
ratio, ok := topupGroupRatio[name] ratio, ok := topupGroupRatio[name]
if !ok { if !ok {
SysError("topup group ratio not found: " + name) SysError(LogText("topup group ratio not found: %s", name))
return 1 return 1
} }
return ratio return ratio
+3 -2
View File
@@ -2,6 +2,7 @@ package common
import ( import (
"crypto/rand" "crypto/rand"
"errors"
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
@@ -130,12 +131,12 @@ func ValidateNumericCode(code string) (string, error) {
code = strings.ReplaceAll(code, " ", "") code = strings.ReplaceAll(code, " ", "")
if len(code) != 6 { if len(code) != 6 {
return "", fmt.Errorf("验证码必须是6位数字") return "", errors.New("the verification code must be 6 digits")
} }
// 检查是否为纯数字 // 检查是否为纯数字
if _, err := strconv.Atoi(code); err != nil { if _, err := strconv.Atoi(code); err != nil {
return "", fmt.Errorf("验证码只能包含数字") return "", errors.New("the verification code must contain only digits")
} }
return code, nil return code, nil
-25
View File
@@ -163,31 +163,6 @@ func Bytes2Size(num int64) string {
return numStr + " " + unit return numStr + " " + unit
} }
func Seconds2Time(num int) (time string) {
if num/31104000 > 0 {
time += strconv.Itoa(num/31104000) + " 年 "
num %= 31104000
}
if num/2592000 > 0 {
time += strconv.Itoa(num/2592000) + " 个月 "
num %= 2592000
}
if num/86400 > 0 {
time += strconv.Itoa(num/86400) + " 天 "
num %= 86400
}
if num/3600 > 0 {
time += strconv.Itoa(num/3600) + " 小时 "
num %= 3600
}
if num/60 > 0 {
time += strconv.Itoa(num/60) + " 分钟 "
num %= 60
}
time += strconv.Itoa(num) + " 秒"
return
}
func Interface2String(inter any) string { func Interface2String(inter any) string {
switch inter.(type) { switch inter.(type) {
case string: case string:
+1 -1
View File
@@ -192,7 +192,7 @@ var ChannelTypeNames = map[int]string{
ChannelTypeJimeng: "Jimeng", ChannelTypeJimeng: "Jimeng",
ChannelTypeVidu: "Vidu", ChannelTypeVidu: "Vidu",
ChannelTypeSubmodel: "Submodel", ChannelTypeSubmodel: "Submodel",
ChannelTypeDoubaoVideo: "DoubaoVideo", ChannelTypeDoubaoVideo: "Doubao",
ChannelTypeSora: "Sora", ChannelTypeSora: "Sora",
ChannelTypeReplicate: "Replicate", ChannelTypeReplicate: "Replicate",
ChannelTypeCodex: "ChatGPT Subscription (Codex)", ChannelTypeCodex: "ChatGPT Subscription (Codex)",
+5
View File
@@ -44,6 +44,11 @@ const (
ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index" ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index"
ContextKeyChannelKey ContextKey = "channel_key" ContextKeyChannelKey ContextKey = "channel_key"
// ContextKeyUpstreamRequestOrigin is the scheme and host of the last upstream
// request, which differs from the base URL for absolute Advanced Custom routes
// and for adaptors that build official endpoints themselves.
ContextKeyUpstreamRequestOrigin ContextKey = "upstream_request_origin"
ContextKeyAutoGroup ContextKey = "auto_group" ContextKeyAutoGroup ContextKey = "auto_group"
ContextKeyAutoGroupIndex ContextKey = "auto_group_index" ContextKeyAutoGroupIndex ContextKey = "auto_group_index"
ContextKeyAutoGroupRetryIndex ContextKey = "auto_group_retry_index" ContextKeyAutoGroupRetryIndex ContextKey = "auto_group_retry_index"
+9
View File
@@ -11,6 +11,8 @@ const (
EndpointTypeOpenAIResponse = types.EndpointTypeOpenAIResponse EndpointTypeOpenAIResponse = types.EndpointTypeOpenAIResponse
EndpointTypeOpenAIResponseCompact = types.EndpointTypeOpenAIResponseCompact EndpointTypeOpenAIResponseCompact = types.EndpointTypeOpenAIResponseCompact
EndpointTypeOpenAIAlphaSearch = types.EndpointTypeOpenAIAlphaSearch EndpointTypeOpenAIAlphaSearch = types.EndpointTypeOpenAIAlphaSearch
EndpointTypeDeepSeekToolsSearch = types.EndpointTypeDeepSeekToolsSearch
EndpointTypeDeepSeekToolsFetch = types.EndpointTypeDeepSeekToolsFetch
EndpointTypeAnthropic = types.EndpointTypeAnthropic EndpointTypeAnthropic = types.EndpointTypeAnthropic
EndpointTypeGemini = types.EndpointTypeGemini EndpointTypeGemini = types.EndpointTypeGemini
EndpointTypeJinaRerank = types.EndpointTypeJinaRerank EndpointTypeJinaRerank = types.EndpointTypeJinaRerank
@@ -18,3 +20,10 @@ const (
EndpointTypeEmbeddings = types.EndpointTypeEmbeddings EndpointTypeEmbeddings = types.EndpointTypeEmbeddings
EndpointTypeOpenAIVideo = types.EndpointTypeOpenAIVideo 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"
)
+299 -20
View File
@@ -1,64 +1,343 @@
package controller package controller
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func GetAccessTokenStatus(c *gin.Context) { // accessTokenMinLifetimeSecs is the shortest expiry a new access token may have.
status, err := model.GetUserAccessTokenStatus(c.GetInt("id")) const accessTokenMinLifetimeSecs = 60 * 60
type accessTokenItem struct {
model.UserAccessToken
Scopes []string `json:"scopes"`
}
type accessTokenCreateRequest struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
ExpiresAt int64 `json:"expires_at"`
}
// accessTokenUpdateRequest renames a token; a present scopes field also
// replaces its grant.
type accessTokenUpdateRequest struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
}
func newAccessTokenItem(token *model.UserAccessToken) accessTokenItem {
scopes := token.GetScopes()
if scopes == nil {
scopes = []string{}
}
return accessTokenItem{UserAccessToken: *token, Scopes: scopes}
}
// writeAccessTokenError responds with a stable code and a web console message.
func writeAccessTokenError(c *gin.Context, status int, code, key string, params ...map[string]any) {
common.ApiErrorStatus(c, status, common.NewMessage(key, params...), gin.H{"code": code})
}
// normalizeAccessTokenName returns the trimmed name, or false when it is empty
// or longer than the 64-character column.
func normalizeAccessTokenName(name string) (string, bool) {
name = strings.TrimSpace(name)
count := utf8.RuneCountInString(name)
return name, count > 0 && count <= 64
}
func ListAccessTokens(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
tokens, err := model.ListUserAccessTokens(identity.UserID)
if err != nil { if err != nil {
writeSecurityOperationError(c, err) writeSecurityOperationError(c, err)
return return
} }
common.ApiSuccess(c, status) items := make([]accessTokenItem, 0, len(tokens))
for i := range tokens {
items = append(items, newAccessTokenItem(&tokens[i]))
}
var legacy gin.H
if !model.LegacyAccessTokensRetired(common.GetTimestamp()) {
status, err := model.GetUserAccessTokenStatus(identity.UserID)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if status.Exists {
legacy = gin.H{
"token_hint": status.TokenHint,
"token_ref": status.TokenRef,
"created_at": status.CreatedAt,
"last_used_at": status.LastUsedAt,
"last_used_ip": status.LastUsedIp,
"retire_at": model.LegacyAccessTokenRetireAt(),
}
}
}
common.ApiSuccess(c, gin.H{"items": items, "legacy": legacy})
} }
func GenerateAccessToken(c *gin.Context) { func GetAccessTokenCatalog(c *gin.Context) {
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate}) == nil { identity, ok := requireBrowserSession(c)
if !ok {
return return
} }
id := c.GetInt("id") common.ApiSuccess(c, gin.H{
key, err := common.GenerateRandomKey(29 + common.GetRandomInt(4)) "groups": service.AccessTokenCatalog(identity.UserID, c.GetInt("role")),
"max_tokens": service.AccessTokenMaxPerUser,
"default_expiry_days": service.AccessTokenDefaultExpiryDays,
})
}
// GetAccessTokenScopes returns the unfiltered scope labels used to describe
// stored grants, such as those in audit records.
func GetAccessTokenScopes(c *gin.Context) {
if _, ok := requireBrowserSession(c); !ok {
return
}
common.ApiSuccess(c, gin.H{"resources": service.AccessTokenScopeDictionary()})
}
// CreateAccessToken validates the whole request before consuming the proof, so
// a rejected grant never burns the user's verification.
func CreateAccessToken(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
var req accessTokenCreateRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
writeAccessTokenError(c, http.StatusBadRequest, "INVALID_REQUEST", "Invalid request body.")
return
}
name, ok := normalizeAccessTokenName(req.Name)
if !ok {
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_NAME_INVALID", "Access token name must be 1 to 64 characters.")
return
}
now := common.GetTimestamp()
if req.ExpiresAt != 0 && req.ExpiresAt < now+accessTokenMinLifetimeSecs {
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_EXPIRY_INVALID", "Access token expiry must be at least one hour from now.")
return
}
scopes, err := service.NormalizeAccessTokenScopes(identity.UserID, c.GetInt("role"), req.Scopes)
switch {
case errors.Is(err, service.ErrAccessTokenScopeForbidden):
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_FORBIDDEN", "You cannot grant one or more of these permissions.")
return
case err != nil:
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_INVALID", "Select at least one valid permission.")
return
}
proofContext, err := common.Marshal(service.AccessTokenGenerateContext{Scopes: scopes, ExpiresAt: req.ExpiresAt})
if err != nil { if err != nil {
writeSecurityOperationError(c, err) writeSecurityOperationError(c, err)
return return
} }
var existing int64 if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate, Context: proofContext}) == nil {
if err := model.DB.Model(&model.User{}).Where("access_token = ?", key).Count(&existing).Error; err != nil { return
}
suffix, err := common.GenerateRandomCharsKey(43)
if err != nil {
writeSecurityOperationError(c, err) writeSecurityOperationError(c, err)
return return
} }
if existing != 0 { raw := model.AccessTokenPrefix + suffix
common.ApiErrorI18n(c, i18n.MsgUuidDuplicate) token := &model.UserAccessToken{
return Name: name,
TokenHash: model.AccessTokenFingerprint(raw),
TokenHint: model.AccessTokenHint(raw),
ExpiresAt: req.ExpiresAt,
CreatedAt: now,
} }
if err := model.UpdateUserAccessToken(id, key); err != nil { if err := token.SetScopes(scopes); err != nil {
writeSecurityOperationError(c, err) writeSecurityOperationError(c, err)
return return
} }
recordUserSecurityAudit(c, id, "access_token.generate", map[string]any{"token_ref": model.AccessTokenFingerprint(key)}) if err := model.CreateUserAccessToken(identity.UserID, token, service.AccessTokenMaxPerUser); err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": key}) if errors.Is(err, model.ErrAccessTokenLimit) {
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_LIMIT", "You can create at most {{count}} access tokens", map[string]any{"count": service.AccessTokenMaxPerUser})
return
}
writeSecurityOperationError(c, err)
return
}
recordUserSecurityAudit(c, identity.UserID, "access_token.generate", map[string]any{
"token_id": token.Id,
"name": token.Name,
"scopes": scopes,
"expires_at": token.ExpiresAt,
"token_ref": token.TokenHash,
})
common.ApiSuccess(c, gin.H{"token": raw, "item": newAccessTokenItem(token)})
} }
func RevokeAccessToken(c *gin.Context) { // UpdateAccessToken renames a token without verification. Changing the grant
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke}) == nil { // is checked in full before the proof is consumed, like creation.
func UpdateAccessToken(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return return
} }
ref, err := model.RevokeUserAccessToken(c.GetInt("id")) id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
var req accessTokenUpdateRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
writeAccessTokenError(c, http.StatusBadRequest, "INVALID_REQUEST", "Invalid request body.")
return
}
name, ok := normalizeAccessTokenName(req.Name)
if !ok {
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_NAME_INVALID", "Access token name must be 1 to 64 characters.")
return
}
var scopes []string
var previous *model.UserAccessToken
if req.Scopes != nil {
previous, err = model.GetUserAccessToken(identity.UserID, id)
if errors.Is(err, model.ErrAccessTokenNotFound) {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
if err != nil {
writeSecurityOperationError(c, err)
return
}
scopes, err = service.NormalizeAccessTokenScopes(identity.UserID, c.GetInt("role"), req.Scopes)
switch {
case errors.Is(err, service.ErrAccessTokenScopeForbidden):
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_FORBIDDEN", "You cannot grant one or more of these permissions.")
return
case err != nil:
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_INVALID", "Select at least one valid permission.")
return
}
proofContext, err := common.Marshal(service.AccessTokenUpdateContext{TokenID: id, Scopes: scopes})
if err != nil {
writeSecurityOperationError(c, err)
return
}
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenUpdate, Context: proofContext}) == nil {
return
}
}
token, err := model.UpdateUserAccessToken(identity.UserID, id, name, scopes)
if errors.Is(err, model.ErrAccessTokenNotFound) {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
if err != nil {
writeSecurityOperationError(c, err)
return
}
if previous == nil {
recordUserSecurityAudit(c, identity.UserID, "access_token.rename", map[string]any{"token_id": token.Id, "name": token.Name})
common.ApiSuccess(c, newAccessTokenItem(token))
return
}
recordUserSecurityAudit(c, identity.UserID, "access_token.update", map[string]any{
"token_id": token.Id,
"name": token.Name,
"scopes": scopes,
"previous_scopes": previous.GetScopes(),
"token_ref": token.TokenHash,
})
common.ApiSuccess(c, newAccessTokenItem(token))
}
// DeleteAccessToken checks ownership before consuming the proof, so probing
// other users' token IDs never burns a verification.
func DeleteAccessToken(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
if _, err := model.GetUserAccessToken(identity.UserID, id); err != nil {
if errors.Is(err, model.ErrAccessTokenNotFound) {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
writeSecurityOperationError(c, err)
return
}
proofContext, err := common.Marshal(service.AccessTokenRevokeContext{TokenID: id})
if err != nil {
writeSecurityOperationError(c, err)
return
}
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: proofContext}) == nil {
return
}
token, err := model.DeleteUserAccessToken(identity.UserID, id)
if errors.Is(err, model.ErrAccessTokenNotFound) {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
if err != nil {
writeSecurityOperationError(c, err)
return
}
recordUserSecurityAudit(c, identity.UserID, "access_token.revoke", map[string]any{"token_id": token.Id, "name": token.Name, "token_ref": token.TokenHash})
common.ApiSuccess(c, nil)
}
// RevokeLegacyAccessToken removes the pre-scope users.access_token credential.
// After the transition deadline the legacy token no longer exists for callers.
func RevokeLegacyAccessToken(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
if model.LegacyAccessTokensRetired(common.GetTimestamp()) {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
status, err := model.GetUserAccessTokenStatus(identity.UserID)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if !status.Exists {
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
return
}
proofContext, err := common.Marshal(service.AccessTokenRevokeContext{Legacy: true})
if err != nil {
writeSecurityOperationError(c, err)
return
}
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: proofContext}) == nil {
return
}
ref, err := model.RevokeUserAccessToken(identity.UserID)
if err != nil { if err != nil {
writeSecurityOperationError(c, err) writeSecurityOperationError(c, err)
return return
} }
if ref != "" { if ref != "" {
recordUserSecurityAudit(c, c.GetInt("id"), "access_token.revoke", map[string]any{"token_ref": ref}) recordUserSecurityAudit(c, identity.UserID, "access_token.revoke", map[string]any{"legacy": true, "token_ref": ref})
} }
common.ApiSuccess(c, nil) common.ApiSuccess(c, nil)
} }
+453 -46
View File
@@ -17,6 +17,7 @@ import (
"gorm.io/driver/clickhouse" "gorm.io/driver/clickhouse"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
@@ -37,9 +38,12 @@ func setupAccessTokenAudit(t *testing.T) (*model.User, string) {
previousRedis := common.RedisEnabled previousRedis := common.RedisEnabled
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.Log{}, &model.AuditLog{}, &model.CasbinRule{}, &model.AuthzRole{})) require.NoError(t, i18n.Init())
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.Log{}, &model.AuditLog{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.UserAccessToken{}, &model.Option{},
&model.AuthFlow{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.UserOAuthBinding{}))
model.DB, model.LOG_DB = db, db model.DB, model.LOG_DB = db, db
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite) common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
common.RedisEnabled = false common.RedisEnabled = false
previousMaster := common.IsMasterNode previousMaster := common.IsMasterNode
common.IsMasterNode = true common.IsMasterNode = true
@@ -66,56 +70,447 @@ func auditRequest(router http.Handler, method, path, token string) *httptest.Res
return response return response
} }
func TestAccessTokenLifecycleAndLateRequests(t *testing.T) { // newAccessTokenTestRouter mirrors the production paths, so the access token
user, old := setupAccessTokenAudit(t) // route rules keyed by gin full path apply unchanged.
func newAccessTokenTestRouter() *gin.Engine {
router := gin.New() router := gin.New()
router.Use(middleware.RequestId(), middleware.AccessTokenAudit()) router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
router.GET("/api/user/token/status", middleware.UserAuth(), GetAccessTokenStatus) api := router.Group("/api")
router.GET("/api/user/token", middleware.UserAuth(), GenerateAccessToken) api.GET("/verify/methods", middleware.UserAuth(), GetVerificationMethods)
router.POST("/api/user/token", middleware.UserAuth(), GenerateAccessToken) api.POST("/verify", middleware.UserAuth(), UniversalVerify)
router.DELETE("/api/user/token", middleware.UserAuth(), RevokeAccessToken) userRoute := api.Group("/user")
// Rotate during the handler, after authentication has captured the old PAT. selfRoute := userRoute.Group("/", middleware.UserAuth())
router.POST("/rotate-in-flight", middleware.UserAuth(), func(c *gin.Context) { selfRoute.GET("/self", func(c *gin.Context) { common.ApiSuccess(c, gin.H{"id": c.GetInt("id")}) })
require.NoError(t, model.UpdateUserAccessToken(user.Id, "new-token")) selfRoute.POST("/2fa/setup", Setup2FA)
c.JSON(200, gin.H{"success": true}) selfRoute.POST("/2fa/disable", Disable2FA)
}) tokenRoute := selfRoute.Group("/access_tokens")
status, err := model.GetUserAccessTokenStatus(user.Id) tokenRoute.GET("", ListAccessTokens)
require.NoError(t, err) tokenRoute.GET("/catalog", GetAccessTokenCatalog)
assert.True(t, status.Exists) tokenRoute.GET("/scopes", GetAccessTokenScopes)
assert.Nil(t, status.CreatedAt) tokenRoute.POST("", CreateAccessToken)
assert.Nil(t, status.LastUsedAt) tokenRoute.PATCH("/:id", UpdateAccessToken)
assert.NotContains(t, auditRequest(router, "GET", "/api/user/token/status", old).Body.String(), old) tokenRoute.DELETE("/:id", DeleteAccessToken)
require.Equal(t, 200, auditRequest(router, "POST", "/rotate-in-flight", old).Code) tokenRoute.DELETE("/legacy", RevokeLegacyAccessToken)
status, err = model.GetUserAccessTokenStatus(user.Id) adminRoute := userRoute.Group("/", middleware.AdminAuth())
require.NoError(t, err) adminRoute.DELETE("/:id", DeleteUser)
assert.Equal(t, model.AccessTokenFingerprint("new-token"), status.TokenRef) return router
assert.NotNil(t, status.CreatedAt) }
assert.Nil(t, status.LastUsedAt, "in-flight old requests must not mark the new generation as used")
assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", old).Code) func accessTokenRequest(router http.Handler, method, path, credential, proof, body string) *httptest.ResponseRecorder {
for _, method := range []string{"POST", "GET", "DELETE"} { request := httptest.NewRequest(method, path, strings.NewReader(body))
response := auditRequest(router, method, "/api/user/token", "new-token") request.Header.Set("Authorization", "Bearer "+credential)
assert.Equal(t, http.StatusForbidden, response.Code) request.Header.Set("Content-Type", "application/json")
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_INVALID"`) if proof != "" {
stored, err := model.GetUserById(user.Id, true) request.Header.Set("X-Security-Proof", proof)
require.NoError(t, err)
assert.Equal(t, "new-token", stored.GetAccessToken(), "a PAT cannot manage itself without a dashboard verification")
} }
_, err = model.RevokeUserAccessToken(user.Id) request.RemoteAddr = "192.0.2.8:4567"
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func createScopedAccessToken(t *testing.T, userID int, expiresAt int64, scopes ...string) (string, *model.UserAccessToken) {
t.Helper()
suffix, err := common.GenerateRandomCharsKey(43)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", "new-token").Code) raw := model.AccessTokenPrefix + suffix
ref, err := model.RevokeUserAccessToken(user.Id) token := &model.UserAccessToken{Name: "test token", TokenHash: model.AccessTokenFingerprint(raw), TokenHint: model.AccessTokenHint(raw), ExpiresAt: expiresAt}
require.NoError(t, token.SetScopes(scopes))
require.NoError(t, model.CreateUserAccessToken(userID, token, service.AccessTokenMaxPerUser))
return raw, token
}
// createAccessTokenTestSession returns a browser session identity and the
// dashboard JWT that carries it.
func createAccessTokenTestSession(t *testing.T, userID int, sid string) (service.AuthIdentity, string) {
t.Helper()
now := time.Now().Unix()
require.NoError(t, model.CreateUserSession(&model.UserSession{SID: sid, UserID: userID, Version: 1, UserAuthVersion: 1, Status: model.UserSessionStatusActive, RefreshHash: "refresh-" + sid, LoginMethod: "password", LastActiveAt: now, ExpiresAt: now + 3600}))
identity := service.AuthIdentity{UserID: userID, SessionID: sid, UserAuthVersion: 1, SessionVersion: 1}
jwt, _, err := service.IssueAccessToken(identity)
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, ref, "repeated revocation is idempotent") return identity, jwt
status, err = model.GetUserAccessTokenStatus(user.Id) }
func issueAccessTokenGenerateProof(t *testing.T, identity service.AuthIdentity, expiresAt int64, scopes ...string) string {
t.Helper()
context, err := common.Marshal(service.AccessTokenGenerateContext{Scopes: scopes, ExpiresAt: expiresAt})
require.NoError(t, err) require.NoError(t, err)
assert.False(t, status.Exists) return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate, Context: context}, "password")
assert.Nil(t, status.CreatedAt) }
func issueAccessTokenRevokeProof(t *testing.T, identity service.AuthIdentity, target service.AccessTokenRevokeContext) string {
t.Helper()
context, err := common.Marshal(target)
require.NoError(t, err)
return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: context}, "password")
}
func issueAccessTokenUpdateProof(t *testing.T, identity service.AuthIdentity, tokenID int, scopes ...string) string {
t.Helper()
context, err := common.Marshal(service.AccessTokenUpdateContext{TokenID: tokenID, Scopes: scopes})
require.NoError(t, err)
return issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenUpdate, Context: context}, "password")
}
// setLegacyAccessTokenRetireAt moves the stored transition deadline the way an
// operator restoring the option row would, then reloads it.
func setLegacyAccessTokenRetireAt(t *testing.T, retireAt int64) {
t.Helper()
require.NoError(t, model.DB.Model(&model.Option{}).Where(&model.Option{Key: "LegacyAccessTokenRetireAt"}).Update("value", fmt.Sprint(retireAt)).Error)
require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
require.Equal(t, retireAt, model.LegacyAccessTokenRetireAt())
}
type accessTokenListResponse struct {
Success bool `json:"success"`
Code string `json:"code"`
Data struct {
Items []struct {
Id int `json:"id"`
Name string `json:"name"`
TokenRef string `json:"token_ref"`
Scopes []string `json:"scopes"`
} `json:"items"`
Legacy *struct {
TokenRef string `json:"token_ref"`
RetireAt int64 `json:"retire_at"`
} `json:"legacy"`
} `json:"data"`
}
func TestAccessTokenLifecycleAndLateRequests(t *testing.T) {
user, legacy := setupAccessTokenAudit(t)
router := newAccessTokenTestRouter()
identity, browser := createAccessTokenTestSession(t, user.Id, "lifecycle-session")
scoped, scopedToken := createScopedAccessToken(t, user.Id, 0, "profile:read")
tokenPath := fmt.Sprintf("/api/user/access_tokens/%d", scopedToken.Id)
for _, credential := range []string{scoped, legacy} {
for _, endpoint := range []struct{ method, path string }{
{"GET", "/api/user/access_tokens"}, {"GET", "/api/user/access_tokens/catalog"}, {"GET", "/api/user/access_tokens/scopes"}, {"POST", "/api/user/access_tokens"},
{"PATCH", tokenPath}, {"DELETE", tokenPath}, {"DELETE", "/api/user/access_tokens/legacy"},
} {
response := accessTokenRequest(router, endpoint.method, endpoint.path, credential, "", `{"name":"renamed","scopes":["profile:read"],"expires_at":0}`)
assert.Equal(t, http.StatusForbidden, response.Code, endpoint.method+" "+endpoint.path)
assert.Contains(t, response.Body.String(), `"code":"AUTH_SESSION_REQUIRED"`)
}
}
tokens, err := model.ListUserAccessTokens(user.Id)
require.NoError(t, err)
require.Len(t, tokens, 1)
assert.Equal(t, "test token", tokens[0].Name, "an access token cannot manage access tokens")
response := accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
require.Equal(t, http.StatusOK, response.Code)
for _, secret := range []string{scoped, legacy} {
assert.NotContains(t, response.Body.String(), secret)
}
var listed accessTokenListResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
require.Len(t, listed.Data.Items, 1)
assert.Equal(t, scopedToken.TokenHash, listed.Data.Items[0].TokenRef)
assert.Equal(t, []string{"profile:read"}, listed.Data.Items[0].Scopes)
require.NotNil(t, listed.Data.Legacy)
assert.Equal(t, model.AccessTokenFingerprint(legacy), listed.Data.Legacy.TokenRef)
assert.Equal(t, model.LegacyAccessTokenRetireAt(), listed.Data.Legacy.RetireAt)
// A token id only resolves among the caller's own tokens.
other := &model.User{Username: "audit-other", Password: "placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "audit-other"}
require.NoError(t, model.DB.Create(other).Error)
otherRaw, otherToken := createScopedAccessToken(t, other.Id, 0, "profile:read")
otherProof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{TokenID: otherToken.Id})
response = accessTokenRequest(router, "DELETE", fmt.Sprintf("/api/user/access_tokens/%d", otherToken.Id), browser, otherProof, "")
assert.Equal(t, http.StatusNotFound, response.Code)
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_NOT_FOUND"`)
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", otherRaw, "", "").Code)
// A revoke proof names one target and is not spent on another one.
proof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{TokenID: scopedToken.Id})
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, proof, "")
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONTEXT_MISMATCH"`)
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
response = accessTokenRequest(router, "DELETE", tokenPath, browser, proof, "")
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, "GET", "/api/user/self", scoped, "", "").Code)
response = accessTokenRequest(router, "DELETE", tokenPath, browser, proof, "")
assert.Equal(t, http.StatusNotFound, response.Code)
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_NOT_FOUND"`)
legacyProof := issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{Legacy: true})
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, legacyProof, "")
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
response = accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
listed = accessTokenListResponse{}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
assert.Empty(t, listed.Data.Items)
assert.Nil(t, listed.Data.Legacy)
// After the deadline a stored legacy value is inert: it is neither listed,
// revocable nor accepted, and the column itself is left untouched.
require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("access_token", legacy).Error)
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "").Code)
retireAt := model.LegacyAccessTokenRetireAt()
setLegacyAccessTokenRetireAt(t, time.Now().Unix()-1)
t.Cleanup(func() { setLegacyAccessTokenRetireAt(t, retireAt) })
response = accessTokenRequest(router, "GET", "/api/user/access_tokens", browser, "", "")
listed = accessTokenListResponse{}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &listed))
assert.Nil(t, listed.Data.Legacy)
legacyProof = issueAccessTokenRevokeProof(t, identity, service.AccessTokenRevokeContext{Legacy: true})
response = accessTokenRequest(router, "DELETE", "/api/user/access_tokens/legacy", browser, legacyProof, "")
assert.Equal(t, http.StatusNotFound, response.Code)
response = accessTokenRequest(router, "GET", "/api/user/self", legacy, "", "")
assert.Equal(t, http.StatusUnauthorized, response.Code)
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_LEGACY_RETIRED"`)
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.Equal(t, legacy, stored.GetAccessToken())
var revoked []model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "access_token.revoke").Order("id").Find(&revoked).Error)
require.Len(t, revoked, 2)
for i, want := range []string{
fmt.Sprintf(`"token_ref":"%s"`, scopedToken.TokenHash),
fmt.Sprintf(`"legacy":true,"token_ref":"%s"`, model.AccessTokenFingerprint(legacy)),
} {
require.NotNil(t, revoked[i].Other.Op)
params, err := common.Marshal(revoked[i].Other.Op.Params)
require.NoError(t, err)
assert.Contains(t, string(params), want)
}
var history []model.AuditLog var history []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&history).Error) require.NoError(t, model.LOG_DB.Find(&history).Error)
require.NotEmpty(t, history)
encoded, err := common.Marshal(history) encoded, err := common.Marshal(history)
require.NoError(t, err) require.NoError(t, err)
for _, secret := range []string{old, "new-token", "body-must-not-be-logged", "Authorization"} { for _, secret := range []string{scoped, legacy, browser, proof, legacyProof, otherRaw, otherProof, "Authorization"} {
assert.NotContains(t, string(encoded), secret)
}
}
// The scope dictionary labels stored grants, so it must not shrink to what the
// viewer can grant today.
func TestAccessTokenScopeDictionaryIgnoresTheViewersGrants(t *testing.T) {
user, _ := setupAccessTokenAudit(t)
require.NoError(t, model.DB.Model(user).Update("role", common.RoleCommonUser).Error)
router := newAccessTokenTestRouter()
_, browser := createAccessTokenTestSession(t, user.Id, "dictionary-session")
response := accessTokenRequest(router, "GET", "/api/user/access_tokens/scopes", browser, "", "")
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
var dictionary struct {
Data struct {
Resources []struct {
Resource string `json:"resource"`
LabelKey string `json:"label_key"`
Actions []struct {
Action string `json:"action"`
LabelKey string `json:"label_key"`
} `json:"actions"`
} `json:"resources"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &dictionary))
labels := map[string]string{}
for _, resource := range dictionary.Data.Resources {
for _, action := range resource.Actions {
labels[resource.Resource+":"+action.Action] = resource.LabelKey + " / " + action.LabelKey
}
}
assert.Equal(t, "Profile / View", labels["profile:read"])
assert.Equal(t, "API keys / Reveal full keys", labels["api_key:reveal"])
assert.Equal(t, "Users / Edit", labels["user:write"])
assert.Equal(t, "System settings / Edit", labels["option:write"])
for _, permission := range authz.AllPermissions() {
assert.Contains(t, labels, service.AccessTokenScopeOf(permission))
}
response = accessTokenRequest(router, "GET", "/api/user/access_tokens/catalog", browser, "", "")
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
assert.NotContains(t, response.Body.String(), `"resource":"user"`, "the grant catalog stays filtered")
}
func TestAccessTokenCreationChecksGrantBeforeConsumingProof(t *testing.T) {
user, legacy := setupAccessTokenAudit(t)
router := newAccessTokenTestRouter()
identity, browser := createAccessTokenTestSession(t, user.Id, "create-session")
response := accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, "", `{"name":"ci","scopes":["profile:read"],"expires_at":0}`)
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_REQUIRED"`)
expiresAt := time.Now().Unix() + 7*24*60*60
for _, test := range []struct {
name, body, code string
role, status int
proofScopes []string
proofExpiry int64
}{
{"common user cannot grant user management", `{"name":"ci","scopes":["user:write"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_FORBIDDEN", common.RoleCommonUser, http.StatusBadRequest, []string{"user:write"}, 0},
{"administrator cannot grant a permission they lack", `{"name":"ci","scopes":["channel:sensitive_write"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_FORBIDDEN", common.RoleAdminUser, http.StatusBadRequest, []string{"channel:sensitive_write"}, 0},
{"unknown scope", `{"name":"ci","scopes":["profile:admin"],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:admin"}, 0},
{"empty grant", `{"name":"ci","scopes":[],"expires_at":0}`, "ACCESS_TOKEN_SCOPE_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, 0},
{"expiry under one hour", fmt.Sprintf(`{"name":"ci","scopes":["profile:read"],"expires_at":%d}`, time.Now().Unix()+60), "ACCESS_TOKEN_EXPIRY_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, time.Now().Unix() + 60},
{"blank name", `{"name":" ","scopes":["profile:read"],"expires_at":0}`, "ACCESS_TOKEN_NAME_INVALID", common.RoleAdminUser, http.StatusBadRequest, []string{"profile:read"}, 0},
{"proof for other scopes", `{"name":"ci","scopes":["profile:read","usage:read"],"expires_at":0}`, "SECURITY_PROOF_CONTEXT_MISMATCH", common.RoleAdminUser, http.StatusForbidden, []string{"profile:read"}, 0},
{"proof for other expiry", fmt.Sprintf(`{"name":"ci","scopes":["profile:read"],"expires_at":%d}`, expiresAt), "SECURITY_PROOF_CONTEXT_MISMATCH", common.RoleAdminUser, http.StatusForbidden, []string{"profile:read"}, 0},
} {
t.Run(test.name, func(t *testing.T) {
require.NoError(t, model.DB.Model(user).Update("role", test.role).Error)
proof := issueAccessTokenGenerateProof(t, identity, test.proofExpiry, test.proofScopes...)
response := accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, test.body)
assert.Equal(t, test.status, response.Code)
assert.Contains(t, response.Body.String(), `"code":"`+test.code+`"`)
var consumed int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("consumed_at IS NOT NULL").Count(&consumed).Error)
assert.Zero(t, consumed, "a rejected grant must not spend the verification")
})
}
tokens, err := model.ListUserAccessTokens(user.Id)
require.NoError(t, err)
assert.Empty(t, tokens)
require.NoError(t, model.DB.Model(user).Update("role", common.RoleAdminUser).Error)
proof := issueAccessTokenGenerateProof(t, identity, 0, "usage:read", "profile:read")
response = accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, `{"name":" ci ","scopes":["profile:read","usage:read","profile:read"],"expires_at":0}`)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
var created struct {
Data struct {
Token string `json:"token"`
Item struct {
Id int `json:"id"`
Name string `json:"name"`
TokenRef string `json:"token_ref"`
TokenHint string `json:"token_hint"`
ExpiresAt int64 `json:"expires_at"`
Scopes []string `json:"scopes"`
} `json:"item"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &created))
raw := created.Data.Token
require.True(t, strings.HasPrefix(raw, model.AccessTokenPrefix))
assert.Len(t, raw, len(model.AccessTokenPrefix)+43)
assert.Equal(t, "ci", created.Data.Item.Name)
assert.Equal(t, []string{"profile:read", "usage:read"}, created.Data.Item.Scopes)
assert.Zero(t, created.Data.Item.ExpiresAt, "expires_at 0 creates a token that never expires")
assert.Equal(t, model.AccessTokenFingerprint(raw), created.Data.Item.TokenRef)
assert.Equal(t, raw[len(raw)-4:], created.Data.Item.TokenHint)
var columns []map[string]any
require.NoError(t, model.DB.Table("user_access_tokens").Find(&columns).Error)
require.Len(t, columns, 1)
encodedRow, err := common.Marshal(columns[0])
require.NoError(t, err)
assert.NotContains(t, string(encodedRow), raw, "the database stores only the token hash")
response = accessTokenRequest(router, "POST", "/api/user/access_tokens", browser, proof, `{"name":"ci","scopes":["profile:read","usage:read"],"expires_at":0}`)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", raw, "", "").Code)
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.Equal(t, legacy, stored.GetAccessToken(), "creating a token never replaces the legacy token")
var generated model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "access_token.generate").First(&generated).Error)
require.NotNil(t, generated.Other.Op)
params, err := common.Marshal(generated.Other.Op.Params)
require.NoError(t, err)
assert.Contains(t, string(params), fmt.Sprintf(`"token_ref":"%s"`, created.Data.Item.TokenRef))
assert.Contains(t, string(params), `"scopes":["profile:read","usage:read"]`)
var history []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&history).Error)
encoded, err := common.Marshal(history)
require.NoError(t, err)
for _, secret := range []string{raw, browser, proof} {
assert.NotContains(t, string(encoded), secret)
}
}
func TestAccessTokenUpdateRenamesFreelyAndChangesGrantOnlyWithProof(t *testing.T) {
user, _ := setupAccessTokenAudit(t)
router := newAccessTokenTestRouter()
identity, browser := createAccessTokenTestSession(t, user.Id, "update-session")
raw, token := createScopedAccessToken(t, user.Id, 0, "profile:read")
tokenPath := fmt.Sprintf("/api/user/access_tokens/%d", token.Id)
other := &model.User{Username: "audit-other", Password: "placeholder", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "audit-other"}
require.NoError(t, model.DB.Create(other).Error)
_, otherToken := createScopedAccessToken(t, other.Id, 0, "profile:read")
response := accessTokenRequest(router, "PATCH", tokenPath, browser, "", `{"name":" renamed "}`)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
stored, err := model.GetUserAccessToken(user.Id, token.Id)
require.NoError(t, err)
assert.Equal(t, "renamed", stored.Name)
assert.Equal(t, []string{"profile:read"}, stored.GetScopes(), "a rename keeps the grant")
response = accessTokenRequest(router, "PATCH", tokenPath, browser, "", `{"name":"ci","scopes":["usage:read"]}`)
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_REQUIRED"`)
generateProof := issueAccessTokenGenerateProof(t, identity, 0, "usage:read")
for _, test := range []struct {
name, path, body, proof, code string
status int
}{
{"permission the owner lacks", tokenPath, `{"name":"ci","scopes":["channel:sensitive_write"]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "channel:sensitive_write"), "ACCESS_TOKEN_SCOPE_FORBIDDEN", http.StatusBadRequest},
{"empty grant", tokenPath, `{"name":"ci","scopes":[]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "profile:read"), "ACCESS_TOKEN_SCOPE_INVALID", http.StatusBadRequest},
{"another user's token", fmt.Sprintf("/api/user/access_tokens/%d", otherToken.Id), `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, otherToken.Id, "usage:read"), "ACCESS_TOKEN_NOT_FOUND", http.StatusNotFound},
{"proof for another token", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, otherToken.Id, "usage:read"), "SECURITY_PROOF_CONTEXT_MISMATCH", http.StatusForbidden},
{"proof for another grant", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, issueAccessTokenUpdateProof(t, identity, token.Id, "profile:read"), "SECURITY_PROOF_CONTEXT_MISMATCH", http.StatusForbidden},
{"creation proof", tokenPath, `{"name":"ci","scopes":["usage:read"]}`, generateProof, "SECURITY_PROOF_SCOPE_MISMATCH", http.StatusForbidden},
} {
t.Run(test.name, func(t *testing.T) {
response := accessTokenRequest(router, "PATCH", test.path, browser, test.proof, test.body)
assert.Equal(t, test.status, response.Code)
assert.Contains(t, response.Body.String(), `"code":"`+test.code+`"`)
var consumed int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("consumed_at IS NOT NULL").Count(&consumed).Error)
assert.Zero(t, consumed, "a rejected change must not spend the verification")
})
}
stored, err = model.GetUserAccessToken(user.Id, token.Id)
require.NoError(t, err)
assert.Equal(t, "renamed", stored.Name, "a rejected grant change does not apply the rename either")
assert.Equal(t, []string{"profile:read"}, stored.GetScopes())
assert.Equal(t, http.StatusOK, accessTokenRequest(router, "GET", "/api/user/self", raw, "", "").Code)
// The proof binds the grant as a set, so its order and repeats do not matter.
widenProof := issueAccessTokenUpdateProof(t, identity, token.Id, "usage:read", "profile:read", "profile:read")
response = accessTokenRequest(router, "PATCH", tokenPath, browser, widenProof, `{"name":"ci","scopes":["profile:read","usage:read"]}`)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
stored, err = model.GetUserAccessToken(user.Id, token.Id)
require.NoError(t, err)
assert.Equal(t, []string{"profile:read", "usage:read"}, stored.GetScopes())
proof := issueAccessTokenUpdateProof(t, identity, token.Id, "usage:read")
response = accessTokenRequest(router, "PATCH", tokenPath, browser, proof, `{"name":"ci","scopes":["usage:read"," usage:read"]}`)
require.Equal(t, http.StatusOK, response.Code, response.Body.String())
var updated struct {
Data struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &updated))
assert.Equal(t, "ci", updated.Data.Name)
assert.Equal(t, []string{"usage:read"}, updated.Data.Scopes)
response = accessTokenRequest(router, "GET", "/api/user/self", raw, "", "")
assert.Equal(t, http.StatusForbidden, response.Code, "a narrowed grant applies to the next request")
assert.Contains(t, response.Body.String(), `"code":"ACCESS_TOKEN_SCOPE_DENIED"`)
response = accessTokenRequest(router, "PATCH", tokenPath, browser, proof, `{"name":"ci","scopes":["usage:read"]}`)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Where("action IN ?", []string{"access_token.rename", "access_token.update"}).Order("id").Find(&audits).Error)
require.Len(t, audits, 3)
assert.Equal(t, "access_token.rename", audits[0].Action)
assert.Equal(t, "access_token.update", audits[1].Action)
assert.Equal(t, "access_token.update", audits[2].Action)
require.NotNil(t, audits[2].Other.Op)
params, err := common.Marshal(audits[2].Other.Op.Params)
require.NoError(t, err)
assert.Contains(t, string(params), `"previous_scopes":["profile:read","usage:read"]`)
assert.Contains(t, string(params), `"scopes":["usage:read"]`)
assert.Contains(t, string(params), fmt.Sprintf(`"token_ref":"%s"`, token.TokenHash))
encoded, err := common.Marshal(audits)
require.NoError(t, err)
for _, secret := range []string{raw, browser, widenProof, proof, generateProof} {
assert.NotContains(t, string(encoded), secret) assert.NotContains(t, string(encoded), secret)
} }
} }
@@ -226,7 +621,6 @@ func TestAuditIsolationVisibilityAndFailureContracts(t *testing.T) {
require.Error(t, err, "audit query failure must not look like never used") require.Error(t, err, "audit query failure must not look like never used")
model.LOG_DB.Callback().Query().Remove("audit:fail") model.LOG_DB.Callback().Query().Remove("audit:fail")
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("audit:write-fail", func(tx *gorm.DB) { tx.AddError(errors.New("write unavailable")) })) require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("audit:write-fail", func(tx *gorm.DB) { tx.AddError(errors.New("write unavailable")) }))
require.Error(t, model.UpdateUserAccessToken(user.Id, "replacement"))
_, err = model.RevokeUserAccessToken(user.Id) _, err = model.RevokeUserAccessToken(user.Id)
require.Error(t, err) require.Error(t, err)
model.DB.Callback().Update().Remove("audit:write-fail") model.DB.Callback().Update().Remove("audit:write-fail")
@@ -347,7 +741,7 @@ func TestSecurityAndOperationEventsUseAuditTable(t *testing.T) {
recordManageAudit(c, "option.update", map[string]any{"key": "safe"}) recordManageAudit(c, "option.update", map[string]any{"key": "safe"})
recordSubscriptionResetUserLogs(c, &model.SubscriptionResetResult{ResetCount: 1, PlanId: 1, PlanTitle: "Plan", AffectedUserIds: []int{user.Id}}, &model.AuditAdminInfo{AdminID: user.Id}) recordSubscriptionResetUserLogs(c, &model.SubscriptionResetResult{ResetCount: 1, PlanId: 1, PlanTitle: "Plan", AffectedUserIds: []int{user.Id}}, &model.AuditAdminInfo{AdminID: user.Id})
for _, typ := range []int{model.LogTypeTopup, model.LogTypeConsume, model.LogTypeRefund, model.LogTypeSystem} { for _, typ := range []int{model.LogTypeTopup, model.LogTypeConsume, model.LogTypeRefund, model.LogTypeSystem} {
model.RecordLog(user.Id, typ, "business entry") model.RecordLog(user.Id, typ, common.NewMessage("business entry"))
} }
var audits []model.AuditLog var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&audits).Error) require.NoError(t, model.LOG_DB.Find(&audits).Error)
@@ -703,8 +1097,21 @@ func TestAuditDatabaseMatrix(t *testing.T) {
require.NoError(t, db.First(&old).Error) require.NoError(t, db.First(&old).Error)
assert.Equal(t, "historical login", old.Content) assert.Equal(t, "historical login", old.Content)
} }
require.NoError(t, model.UpdateUserAccessToken(1, "matrix-token")) require.NoError(t, db.Model(&model.User{}).Where("id = ?", 1).Update("access_token", "matrix-token").Error)
require.Error(t, db.Create(&model.User{Username: "duplicate", Password: "placeholder", AffCode: "duplicate-aff", AccessToken: common.GetPointer("matrix-token")}).Error, "PAT uniqueness must survive upgrade") require.Error(t, db.Create(&model.User{Username: "duplicate", Password: "placeholder", AffCode: "duplicate-aff", AccessToken: common.GetPointer("matrix-token")}).Error, "PAT uniqueness must survive upgrade")
scoped, scopedToken := createScopedAccessToken(t, 1, 0, "profile:read")
found, err := model.FindUserAccessTokenByHash(model.AccessTokenFingerprint(scoped))
require.NoError(t, err)
assert.Equal(t, scopedToken.Id, found.Id)
assert.Equal(t, []string{"profile:read"}, found.GetScopes())
require.Error(t, db.Create(&model.UserAccessToken{UserId: 1, Name: "duplicate", TokenHash: scopedToken.TokenHash, TokenHint: "dupe", Scopes: found.Scopes}).Error, "token hashes stay unique")
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "owner", CreatedAt: 99, Category: model.AuditCategoryAccessToken, TokenRef: scopedToken.TokenHash, Ip: "192.0.2.1", Success: true})
byRef, _, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1, TokenRef: scopedToken.TokenHash}, 0, 10, common.RoleCommonUser)
require.NoError(t, err)
require.Len(t, byRef, 1, "audit rows of a scoped token are found by its token_ref")
_, err = model.DeleteUserAccessToken(1, scopedToken.Id)
require.NoError(t, err)
require.NoError(t, model.LOG_DB.Where("token_ref = ?", scopedToken.TokenHash).Delete(&model.AuditLog{}).Error)
for _, timestamp := range []int64{101, 102, 103} { for _, timestamp := range []int64{101, 102, 103} {
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "owner", CreatedAt: timestamp, Category: model.AuditCategoryAccessToken, TokenRef: model.AccessTokenFingerprint("matrix-token"), Ip: "192.0.2.1", Success: timestamp != 102}) model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "owner", CreatedAt: timestamp, Category: model.AuditCategoryAccessToken, TokenRef: model.AccessTokenFingerprint("matrix-token"), Ip: "192.0.2.1", Success: timestamp != 102})
} }
@@ -788,7 +1195,7 @@ func TestIndependentAuditLogStores(t *testing.T) {
require.NoError(t, model.LOG_DB.Where("request_id = ?", "legacy-split-request").Take(&old).Error) require.NoError(t, model.LOG_DB.Where("request_id = ?", "legacy-split-request").Take(&old).Error)
assert.Equal(t, "retained historical login", old.Content) assert.Equal(t, "retained historical login", old.Content)
} }
require.NoError(t, model.UpdateUserAccessToken(1, "independent-pat")) require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", 1).Update("access_token", "independent-pat").Error)
model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "independent", Category: model.AuditCategoryAccessToken, TokenRef: model.AccessTokenFingerprint("independent-pat"), Ip: "192.0.2.8", Success: false, Status: 403}) model.RecordAuditLog(nil, model.AuditLog{ActorRole: common.RoleAdminUser, UserId: 1, Username: "independent", Category: model.AuditCategoryAccessToken, TokenRef: model.AccessTokenFingerprint("independent-pat"), Ip: "192.0.2.8", Success: false, Status: 403})
entries, total, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, common.RoleAdminUser) entries, total, err := model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, common.RoleAdminUser)
require.NoError(t, err) require.NoError(t, err)
@@ -799,7 +1206,7 @@ func TestIndependentAuditLogStores(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, status.LastUsedAt) require.NotNil(t, status.LastUsedAt)
assert.Equal(t, "192.0.2.8", status.LastUsedIp) assert.Equal(t, "192.0.2.8", status.LastUsedIp)
model.RecordLog(1, model.LogTypeTopup, "independent business") model.RecordLog(1, model.LogTypeTopup, common.NewMessage("independent business"))
_, err = model.DeleteOldLogBatch(context.Background(), time.Now().Unix()+1, 100) _, err = model.DeleteOldLogBatch(context.Background(), time.Now().Unix()+1, 100)
require.NoError(t, err) require.NoError(t, err)
_, total, err = model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, 1) _, total, err = model.GetAuditLogs(model.AuditLogFilter{UserId: 1}, 0, 20, 1)
+389
View File
@@ -0,0 +1,389 @@
package controller
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupAdminUserTest promotes the enrolled operator to root and creates a
// managed common user for the administrative endpoints to act on.
func setupAdminUserTest(t *testing.T) (*model.User, service.AuthIdentity, *model.User) {
t.Helper()
operator, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.DB.Model(operator).Update("role", common.RoleRootUser).Error)
require.NoError(t, model.PublishUserAuthCache(operator.Id))
password, err := common.Password2Hash("managed-password")
require.NoError(t, err)
target := &model.User{Username: "managed-user", Password: password, Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "managed-aff", GitHubId: "managed-github"}
require.NoError(t, model.DB.Create(target).Error)
return operator, identity, target
}
// adminUserRequest drives an admin user-management handler with the operator
// context that AdminAuth would have populated for a dashboard session.
func adminUserRequest(method, path, body, proof string, identity service.AuthIdentity, role int, params gin.Params, handler gin.HandlerFunc) *httptest.ResponseRecorder {
response := httptest.NewRecorder()
c, _ := gin.CreateTestContext(response)
c.Request = httptest.NewRequest(method, path, strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
if proof != "" {
c.Request.Header.Set("X-Security-Proof", proof)
}
c.Params = params
c.Set("id", identity.UserID)
c.Set("username", "enrollment-user")
c.Set("role", role)
c.Set("session_id", identity.SessionID)
c.Set("auth_version", identity.UserAuthVersion)
c.Set("session_version", identity.SessionVersion)
handler(c)
return response
}
func TestAdminUserRiskOperationsRequireProofBeforeMutation(t *testing.T) {
for _, test := range []struct {
name, method, path string
body func(target *model.User) string
params func(target *model.User) gin.Params
handler gin.HandlerFunc
unchanged func(t *testing.T, target *model.User)
}{
{
name: "hard delete", method: http.MethodDelete, path: "/api/user/:id", handler: DeleteUser,
params: func(target *model.User) gin.Params { return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}} },
unchanged: func(t *testing.T, target *model.User) {
_, err := model.GetUserById(target.Id, false)
assert.NoError(t, err)
},
},
{
name: "manage disable", method: http.MethodPost, path: "/api/user/manage", handler: ManageUser,
body: func(target *model.User) string { return fmt.Sprintf(`{"id":%d,"action":"disable"}`, target.Id) },
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, common.UserStatusEnabled, stored.Status)
},
},
{
name: "manage promote", method: http.MethodPost, path: "/api/user/manage", handler: ManageUser,
body: func(target *model.User) string { return fmt.Sprintf(`{"id":%d,"action":"promote"}`, target.Id) },
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, common.RoleCommonUser, stored.Role)
},
},
{
name: "manage delete", method: http.MethodPost, path: "/api/user/manage", handler: ManageUser,
body: func(target *model.User) string { return fmt.Sprintf(`{"id":%d,"action":"delete"}`, target.Id) },
unchanged: func(t *testing.T, target *model.User) {
_, err := model.GetUserById(target.Id, false)
assert.NoError(t, err)
},
},
{
name: "password reset", method: http.MethodPut, path: "/api/user/", handler: UpdateUser,
body: func(target *model.User) string {
return fmt.Sprintf(`{"id":%d,"username":"managed-user","display_name":"Managed","password":"replacement-pass"}`, target.Id)
},
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, true)
require.NoError(t, err)
assert.True(t, common.ValidatePasswordAndHash("managed-password", stored.Password))
},
},
{
name: "admin permission matrix", method: http.MethodPut, path: "/api/user/", handler: UpdateUser,
body: func(target *model.User) string {
return fmt.Sprintf(`{"id":%d,"username":"managed-user","display_name":"Managed","admin_permissions":{}}`, target.Id)
},
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, "", stored.DisplayName)
},
},
{
name: "create administrator", method: http.MethodPost, path: "/api/user/", handler: CreateUser,
body: func(*model.User) string { return `{"username":"new-admin","password":"admin-password-1","role":10}` },
unchanged: func(t *testing.T, _ *model.User) {
var count int64
require.NoError(t, model.DB.Model(&model.User{}).Where("username = ?", "new-admin").Count(&count).Error)
assert.Zero(t, count)
},
},
{
name: "passkey reset", method: http.MethodDelete, path: "/api/user/:id/reset_passkey", handler: AdminResetPasskey,
params: func(target *model.User) gin.Params { return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}} },
unchanged: func(t *testing.T, target *model.User) {
_, err := model.GetPasskeyByUserID(target.Id)
assert.NoError(t, err)
},
},
{
name: "two-factor disable", method: http.MethodDelete, path: "/api/user/:id/2fa", handler: AdminDisable2FA,
params: func(target *model.User) gin.Params { return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}} },
unchanged: func(t *testing.T, target *model.User) {
twoFA, err := model.GetTwoFAByUserId(target.Id)
require.NoError(t, err)
assert.True(t, twoFA.IsEnabled)
},
},
{
name: "built-in binding clear", method: http.MethodDelete, path: "/api/user/:id/bindings/:binding_type", handler: AdminClearUserBinding,
params: func(target *model.User) gin.Params {
return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "binding_type", Value: "github"}}
},
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, "managed-github", stored.GitHubId)
},
},
{
name: "custom oauth unbind", method: http.MethodDelete, path: "/api/user/:id/oauth/bindings/:provider_id", handler: UnbindCustomOAuthByAdmin,
params: func(target *model.User) gin.Params {
return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "provider_id", Value: "7"}}
},
unchanged: func(t *testing.T, target *model.User) {
bindings, err := model.GetUserOAuthBindingsByUserId(target.Id)
require.NoError(t, err)
assert.Len(t, bindings, 1)
},
},
} {
t.Run(test.name, func(t *testing.T) {
_, identity, target := setupAdminUserTest(t)
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: target.Id, CredentialID: "managed-passkey", PublicKey: "public-key"}).Error)
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: target.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: target.Id, ProviderId: 7, ProviderUserId: "provider-user"}).Error)
var body string
if test.body != nil {
body = test.body(target)
}
var params gin.Params
if test.params != nil {
params = test.params(target)
}
response := adminUserRequest(test.method, test.path, body, "", identity, common.RoleRootUser, params, test.handler)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, "SECURITY_PROOF_REQUIRED", result.Code)
test.unchanged(t, target)
var flows int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Count(&flows).Error)
assert.Zero(t, flows)
})
}
}
// An administrator (not root) exercises the routine paths without touching the
// Casbin policy store, which the security fixture does not provision.
func TestAdminUserRoutineEditsDoNotRequireProof(t *testing.T) {
_, identity, target := setupAdminUserTest(t)
response := adminUserRequest(http.MethodPut, "/api/user/", fmt.Sprintf(`{"id":%d,"username":"managed-user","display_name":"Renamed","group":"default"}`, target.Id), "", identity, common.RoleAdminUser, nil, UpdateUser)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
stored, err := model.GetUserById(target.Id, true)
require.NoError(t, err)
assert.Equal(t, "Renamed", stored.DisplayName)
assert.True(t, common.ValidatePasswordAndHash("managed-password", stored.Password))
response = adminUserRequest(http.MethodPost, "/api/user/", `{"username":"new-member","password":"member-password-1","role":1}`, "", identity, common.RoleAdminUser, nil, CreateUser)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
var count int64
require.NoError(t, model.DB.Model(&model.User{}).Where("username = ?", "new-member").Count(&count).Error)
assert.EqualValues(t, 1, count)
}
func TestCreateUserRejectsNonStandardRole(t *testing.T) {
_, identity, _ := setupAdminUserTest(t)
for i, test := range []struct {
name string
role int
operatorRole int
}{
{"between common and admin", 5, common.RoleAdminUser},
{"negative", -1, common.RoleAdminUser},
{"between admin and root", 99, common.RoleRootUser},
} {
t.Run(test.name, func(t *testing.T) {
username := fmt.Sprintf("odd-role-%d", i)
body := fmt.Sprintf(`{"username":%q,"password":"member-password-1","role":%d}`, username, test.role)
response := adminUserRequest(http.MethodPost, "/api/user/", body, "", identity, test.operatorRole, nil, CreateUser)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, http.StatusOK, response.Code)
assert.False(t, result.Success)
assert.Empty(t, result.Code)
var count int64
require.NoError(t, model.DB.Model(&model.User{}).Where("username = ?", username).Count(&count).Error)
assert.Zero(t, count)
})
}
}
func TestAdminUserProofIsBoundToTargetAndActionAndConsumedOnce(t *testing.T) {
_, identity, target := setupAdminUserTest(t)
other := &model.User{Username: "other-user", Password: target.Password, Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "other-aff"}
require.NoError(t, model.DB.Create(other).Error)
disableTarget := service.VerificationOperation{Scope: service.VerificationScopeAdminUserManage, Context: []byte(fmt.Sprintf(`{"user_id":%d,"action":"disable"}`, target.Id))}
proof := issueSecurityEnrollmentProof(t, identity, disableTarget, service.VerificationMethodPassword)
for _, test := range []struct {
name, body, code string
params gin.Params
handler gin.HandlerFunc
}{
{"different action", fmt.Sprintf(`{"id":%d,"action":"enable"}`, target.Id), "SECURITY_PROOF_CONTEXT_MISMATCH", nil, ManageUser},
{"different user", fmt.Sprintf(`{"id":%d,"action":"disable"}`, other.Id), "SECURITY_PROOF_CONTEXT_MISMATCH", nil, ManageUser},
{"different scope", "", "SECURITY_PROOF_SCOPE_MISMATCH", gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}}, DeleteUser},
} {
t.Run(test.name, func(t *testing.T) {
response := adminUserRequest(http.MethodPost, "/api/user/manage", test.body, proof, identity, common.RoleRootUser, test.params, test.handler)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, test.code, result.Code)
})
}
for _, id := range []int{target.Id, other.Id} {
stored, err := model.GetUserById(id, false)
require.NoError(t, err)
assert.Equal(t, common.UserStatusEnabled, stored.Status)
}
response := adminUserRequest(http.MethodPost, "/api/user/manage", fmt.Sprintf(`{"id":%d,"action":"disable"}`, target.Id), proof, identity, common.RoleRootUser, nil, ManageUser)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, common.UserStatusDisabled, stored.Status)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "user.manage").Last(&audit).Error)
require.NotNil(t, audit.Other.Op)
verificationMethod, err := common.Marshal(audit.Other.Op.Params["verification_method"])
require.NoError(t, err)
assert.JSONEq(t, `"password"`, string(verificationMethod))
targetUserID, err := common.Marshal(audit.Other.Op.Params["target_user_id"])
require.NoError(t, err)
assert.JSONEq(t, fmt.Sprint(target.Id), string(targetUserID))
auditJSON, err := common.Marshal(audit)
require.NoError(t, err)
assert.NotContains(t, string(auditJSON), proof)
response = adminUserRequest(http.MethodPost, "/api/user/manage", fmt.Sprintf(`{"id":%d,"action":"disable"}`, target.Id), proof, identity, common.RoleRootUser, nil, ManageUser)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, "SECURITY_PROOF_CONSUMED", result.Code)
}
func TestAdminUserBindingProofDistinguishesBuiltInAndCustomBindings(t *testing.T) {
_, identity, target := setupAdminUserTest(t)
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: target.Id, ProviderId: 7, ProviderUserId: "provider-user"}).Error)
custom := service.VerificationOperation{Scope: service.VerificationScopeAdminUserBindingClear, Context: []byte(fmt.Sprintf(`{"user_id":%d,"provider_id":7}`, target.Id))}
proof := issueSecurityEnrollmentProof(t, identity, custom, service.VerificationMethodPassword)
params := gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "binding_type", Value: "github"}}
response := adminUserRequest(http.MethodDelete, "/api/user/:id/bindings/:binding_type", "", proof, identity, common.RoleRootUser, params, AdminClearUserBinding)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, "SECURITY_PROOF_CONTEXT_MISMATCH", result.Code)
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, "managed-github", stored.GitHubId)
params = gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "provider_id", Value: "7"}}
response = adminUserRequest(http.MethodDelete, "/api/user/:id/oauth/bindings/:provider_id", "", proof, identity, common.RoleRootUser, params, UnbindCustomOAuthByAdmin)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
bindings, err := model.GetUserOAuthBindingsByUserId(target.Id)
require.NoError(t, err)
assert.Empty(t, bindings)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "user.binding_clear").Last(&audit).Error)
require.NotNil(t, audit.Other.Op)
providerID, err := common.Marshal(audit.Other.Op.Params["provider_id"])
require.NoError(t, err)
assert.JSONEq(t, "7", string(providerID))
}
func TestAdminUserVerificationPolicy(t *testing.T) {
t.Run("requires an administrator", func(t *testing.T) {
_, identity := setupSecurityEnrollmentTest(t)
_, err := service.GetVerificationRequirements(identity, service.VerificationScopeAdminUserDelete)
assert.ErrorIs(t, err, service.ErrVerificationForbidden)
})
t.Run("falls back to the password without a second factor", func(t *testing.T) {
_, identity, _ := setupAdminUserTest(t)
requirements, err := service.GetVerificationRequirements(identity, service.VerificationScopeAdminUserDelete)
require.NoError(t, err)
assert.Equal(t, []service.VerificationMethodOption{{Method: service.VerificationMethodPassword, Available: true}}, requirements.Methods)
})
t.Run("prefers an enrolled second factor", func(t *testing.T) {
operator, identity, _ := setupAdminUserTest(t)
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: operator.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
requirements, err := service.GetVerificationRequirements(identity, service.VerificationScopeAdminUserManage)
require.NoError(t, err)
assert.Equal(t, []service.VerificationMethodOption{{Method: service.VerificationMethodTwoFA, Available: true}}, requirements.Methods)
})
t.Run("a legacy token cannot present a proof", func(t *testing.T) {
operator, identity, target := setupAdminUserTest(t)
require.NoError(t, model.DB.Model(operator).Update("access_token", "legacy-admin-token").Error)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAdminUserDelete, Context: []byte(fmt.Sprintf(`{"user_id":%d}`, target.Id))}, service.VerificationMethodPassword)
response := accessTokenRequest(newAccessTokenTestRouter(), http.MethodDelete, fmt.Sprintf("/api/user/%d", target.Id), "legacy-admin-token", proof, "")
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, "SECURITY_PROOF_INVALID", decodeSecurityEnrollmentResponse(t, response).Code)
_, err := model.GetUserById(target.Id, false)
assert.NoError(t, err)
})
t.Run("a user:write token completes the deletion on its own proof", func(t *testing.T) {
operator, _, target := setupAdminUserTest(t)
require.NoError(t, model.DB.AutoMigrate(&model.ExternalIdentityClaim{}, &model.Token{}))
raw, _ := createScopedAccessToken(t, operator.Id, 0, "user:write")
router := newAccessTokenTestRouter()
response := accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", fmt.Sprintf(`{"scope":"admin.user.delete","method":"password","password":"enrollment-password","context":{"user_id":%d}}`, target.Id))
body := decodeSecurityEnrollmentResponse(t, response)
require.True(t, body.Success, body.Message)
var proof service.SecurityProof
require.NoError(t, common.Unmarshal(body.Data, &proof))
response = accessTokenRequest(router, http.MethodDelete, fmt.Sprintf("/api/user/%d", target.Id), raw, proof.ProofToken, "")
body = decodeSecurityEnrollmentResponse(t, response)
require.True(t, body.Success, body.Message)
_, err := model.GetUserById(target.Id, true)
assert.Error(t, err)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "user.delete").Last(&audit).Error)
assert.Equal(t, "access_token", audit.AuthMethod)
auditJSON, err := common.Marshal(audit)
require.NoError(t, err)
for _, secret := range []string{raw, proof.ProofToken, "enrollment-password"} {
assert.NotContains(t, string(auditJSON), secret)
}
})
t.Run("a user:read token cannot obtain a proof", func(t *testing.T) {
operator, _, target := setupAdminUserTest(t)
raw, _ := createScopedAccessToken(t, operator.Id, 0, "user:read")
response := accessTokenRequest(newAccessTokenTestRouter(), http.MethodPost, "/api/verify", raw, "", fmt.Sprintf(`{"scope":"admin.user.delete","method":"password","password":"enrollment-password","context":{"user_id":%d}}`, target.Id))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, "SECURITY_ACTION_FORBIDDEN", decodeSecurityEnrollmentResponse(t, response).Code)
var flows int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Count(&flows).Error)
assert.Zero(t, flows)
})
}
+31 -9
View File
@@ -139,10 +139,14 @@ func (astraSamplingHandler) Run(parent context.Context, task *model.SystemTask,
state.Rounds[index].Reason = reason state.Rounds[index].Reason = reason
state.Rounds[index].Usage = usage state.Rounds[index].Usage = usage
state.Rounds[index].FinishedAt = time.Now().Unix() state.Rounds[index].FinishedAt = time.Now().Unix()
if persist() && !payload.Force { if persisted := persist(); persisted {
if err := model.ApplyDetectionScheduleResult(ctx, id, "modeltrace", state.Rounds[index].ID, state.Rounds[index].FinishedAt, service.FingerprintScheduleOutcome(service.AstraSamplingResultStatus(result))); err != nil { service.InvalidateGroupQualityCache()
persistErr = err publishGroupStatusChange()
cancel() if !payload.Force {
if err := model.ApplyDetectionScheduleResult(ctx, id, "modeltrace", state.Rounds[index].ID, state.Rounds[index].FinishedAt, service.FingerprintScheduleOutcome(service.AstraSamplingResultStatus(result))); err != nil {
persistErr = err
cancel()
}
} }
} }
mu.Unlock() mu.Unlock()
@@ -179,6 +183,7 @@ func runModelDetectionProbe(parent context.Context, channel *model.Channel, inde
if err != nil { if err != nil {
return fingerprint.ProbeResponse{}, index, err return fingerprint.ProbeResponse{}, index, err
} }
effort := "low"
count, output := probe.Count, fingerprint.OutputTokens count, output := probe.Count, fingerprint.OutputTokens
messages := []map[string]any{{"role": "user", "content": probe.Prompt}} messages := []map[string]any{{"role": "user", "content": probe.Prompt}}
if kind == "benchmark" { if kind == "benchmark" {
@@ -189,14 +194,23 @@ func runModelDetectionProbe(parent context.Context, channel *model.Channel, inde
count, output = index, benchmark.OutputTokens count, output = index, benchmark.OutputTokens
messages = []map[string]any{{"role": "system", "content": secondary.System}, {"role": "user", "content": secondary.Prompt}} messages = []map[string]any{{"role": "system", "content": secondary.System}, {"role": "user", "content": secondary.Prompt}}
} }
if kind == "candy" { if kind == "vision" {
count, output = 1, service.CandyOutputTokens count, output = 1, service.VisionOutputTokens
messages = []map[string]any{{"role": "user", "content": service.CandyPrompt}} effort = "medium"
messages = []map[string]any{{"role": "user", "content": []map[string]any{
{"type": "input_text", "text": service.VisionPrompt},
{"type": "input_image", "image_url": service.VisionImageURL, "detail": "high"},
}}}
}
if kind == "ciallo" {
count, output = 1, service.VisionOutputTokens
effort = "medium"
messages = []map[string]any{{"role": "user", "content": service.CialloPrompt}}
} }
input, _ := common.Marshal(messages) input, _ := common.Marshal(messages)
stream := true stream := true
limit := uint(output) limit := uint(output)
request := &dto.OpenAIResponsesRequest{Model: fingerprint.Model, Input: input, Stream: &stream, MaxOutputTokens: &limit, Store: []byte("false"), Reasoning: &dto.Reasoning{Effort: "low"}} request := &dto.OpenAIResponsesRequest{Model: fingerprint.Model, Input: input, Stream: &stream, MaxOutputTokens: &limit, Store: []byte("false"), Reasoning: &dto.Reasoning{Effort: effort}}
encoded, err := common.Marshal(request) encoded, err := common.Marshal(request)
if err != nil { if err != nil {
return fingerprint.ProbeResponse{}, count, err return fingerprint.ProbeResponse{}, count, err
@@ -230,6 +244,7 @@ func runModelDetectionProbe(parent context.Context, channel *model.Channel, inde
defer boundedCloser.Close() defer boundedCloser.Close()
body = bounded body = bounded
} }
started := time.Now()
response, err := adaptor.DoRequest(c, info, body) response, err := adaptor.DoRequest(c, info, body)
if err != nil { if err != nil {
return fingerprint.ProbeResponse{}, count, errors.New("probe transport failed") return fingerprint.ProbeResponse{}, count, errors.New("probe transport failed")
@@ -239,12 +254,19 @@ func runModelDetectionProbe(parent context.Context, channel *model.Channel, inde
return fingerprint.ProbeResponse{}, count, errors.New("unsupported probe transport") return fingerprint.ProbeResponse{}, count, errors.New("unsupported probe transport")
} }
defer resp.Body.Close() defer resp.Body.Close()
stopClose := context.AfterFunc(ctx, func() { _ = resp.Body.Close() })
defer stopClose()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fingerprint.ProbeResponse{}, count, errors.New("probe HTTP failure") return fingerprint.ProbeResponse{}, count, errors.New("probe HTTP failure")
} }
// Deliberately bypass DoResponse: this is a platform-funded probe, with no // Deliberately bypass DoResponse: this is a platform-funded probe, with no
// customer token, wallet charge, session binding or generated response relay. // customer token, wallet charge, session binding or generated response relay.
answer, err := fingerprint.ReadResponse(resp.Body, resp.Header.Get("Content-Type")) var firstTokenMS *int64
answer, err := fingerprint.ReadResponse(resp.Body, resp.Header.Get("Content-Type"), func() {
elapsed := time.Since(started).Milliseconds()
firstTokenMS = &elapsed
})
answer.FirstTokenMS = firstTokenMS
return answer, count, err return answer, count, err
} }
+13 -4
View File
@@ -29,8 +29,10 @@ var auditContentTemplates = map[string]string{
"user.binding_clear": "Cleared ${bindingType} binding for user ${username}", "user.binding_clear": "Cleared ${bindingType} binding for user ${username}",
"user.2fa_disable": "Force-disabled two-factor authentication for the user", "user.2fa_disable": "Force-disabled two-factor authentication for the user",
"user.passkey_register": "Registered a passkey", "user.passkey_register": "Registered a passkey",
"access_token.generate": "Generated a system access token", "access_token.generate": "Generated an access token",
"access_token.revoke": "Revoked the system access token", "access_token.revoke": "Revoked an access token",
"access_token.rename": "Renamed an access token",
"access_token.update": "Changed access token permissions",
"user.2fa_setup": "Started two-factor authentication setup", "user.2fa_setup": "Started two-factor authentication setup",
"user.2fa_enable": "Enabled two-factor authentication", "user.2fa_enable": "Enabled two-factor authentication",
"user.2fa_disable_self": "Disabled two-factor authentication", "user.2fa_disable_self": "Disabled two-factor authentication",
@@ -68,8 +70,9 @@ var auditContentTemplates = map[string]string{
"redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)", "redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)",
"redemption.delete_batch": "Batch deleted ${count} redemption codes", "redemption.delete_batch": "Batch deleted ${count} redemption codes",
"subscription.plan_reset": "Reset active subscriptions for plan ${plan_id}", "subscription.plan_reset": "Reset active subscriptions for plan ${plan_id}",
"subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}", "subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}",
"subscription.user_quota_reset": "Administrator reset the quota of subscription plan ${plan_title} (ID: ${plan_id})",
} }
func recordPasskeyDomainAudit(c *gin.Context, change *model.PasskeyDomainChange, confirmed bool, err error) { func recordPasskeyDomainAudit(c *gin.Context, change *model.PasskeyDomainChange, confirmed bool, err error) {
@@ -165,6 +168,12 @@ func recordUserSecurityAudit(c *gin.Context, userId int, action string, params m
} }
params["code"] = code params["code"] = code
} }
if c.GetBool("use_access_token") {
if params == nil {
params = map[string]any{}
}
params["token_ref"] = c.GetString("access_token_ref")
}
var auditInfo *model.AuditRequestInfo var auditInfo *model.AuditRequestInfo
if success, ok := params["success"].(bool); ok { if success, ok := params["success"].(bool); ok {
auditInfo = &model.AuditRequestInfo{ auditInfo = &model.AuditRequestInfo{
+7 -2
View File
@@ -2,10 +2,10 @@ package controller
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"strings" "strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
@@ -170,7 +170,7 @@ func writeAuthSessionError(c *gin.Context, err error) {
// The response body only carries the generic AUTH_INTERNAL_ERROR // The response body only carries the generic AUTH_INTERNAL_ERROR
// code; without this log the underlying Redis/database/session // code; without this log the underlying Redis/database/session
// failure is indistinguishable from the client side. // failure is indistinguishable from the client side.
logger.LogError(c.Request.Context(), fmt.Sprintf("auth session internal error (%s %s): %v", c.Request.Method, c.Request.URL.Path, err)) logger.LogError(c.Request.Context(), common.LogText("auth session internal error (%s %s): %v", c.Request.Method, c.Request.URL.Path, err))
} }
c.JSON(status, gin.H{"success": false, "code": code, "message": http.StatusText(status)}) c.JSON(status, gin.H{"success": false, "code": code, "message": http.StatusText(status)})
} }
@@ -179,7 +179,12 @@ func setAuthNoStore(c *gin.Context) {
c.Header("Cache-Control", "no-store") c.Header("Cache-Control", "no-store")
} }
// authRotationData is empty when the change came through an access token,
// which has no browser session to rotate.
func authRotationData(bundle *service.AuthBundle) gin.H { func authRotationData(bundle *service.AuthBundle) gin.H {
if bundle == nil {
return gin.H{}
}
return gin.H{ return gin.H{
"access_token": bundle.AccessToken, "access_token": bundle.AccessToken,
"token_type": bundle.TokenType, "token_type": bundle.TokenType,
+146
View File
@@ -1,15 +1,25 @@
package controller package controller
import ( import (
"fmt"
"math"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"strconv"
"strings" "strings"
"testing" "testing"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/pkg/jsplugin" "github.com/QuantumNous/new-api/pkg/jsplugin"
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/config" "github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -177,3 +187,139 @@ export function parseTaskResult() { return {}; }
assert.Contains(t, unresolvable.Body.String(), `"success":false`) assert.Contains(t, unresolvable.Body.String(), `"success":false`)
assert.Contains(t, unresolvable.Body.String(), "no task plugin usage schema") assert.Contains(t, unresolvable.Body.String(), "no task plugin usage schema")
} }
// Covers configuration persistence, validation, reservation and reconciliation
// through the same public entry points used by the relay.
func TestPreConsumePolicyDatabaseMatrix(t *testing.T) {
previousConfig := config.GlobalConfig.ExportAllConfigs()
previousUnit, previousBatch := common.QuotaPerUnit, common.BatchUpdateEnabled
common.QuotaPerUnit, common.BatchUpdateEnabled = 500000, false
t.Cleanup(func() {
require.NoError(t, config.GlobalConfig.LoadFromDB(previousConfig))
common.QuotaPerUnit, common.BatchUpdateEnabled = previousUnit, previousBatch
})
for _, dialect := range []struct{ kind, env string }{{"sqlite", ""}, {"mysql", "TEST_MYSQL_DSN"}, {"postgres", "TEST_POSTGRES_DSN"}} {
t.Run(dialect.kind, func(t *testing.T) {
if dialect.env != "" && os.Getenv(dialect.env) == "" {
t.Skip("set " + dialect.env + " to run this database")
}
db := modelManagementDB(t, dialect.kind, os.Getenv(dialect.env))
require.NoError(t, db.AutoMigrate(&model.Token{}))
for key, value := range map[string]string{
"quota_setting.trust_quota_usd": "10.5",
"quota_setting.pre_consume_multiplier": "1.5",
} {
var response struct{ Success bool }
modelManagementRequest(t, UpdateOption, http.MethodPut, "/api/option/", OptionUpdateRequest{Key: key, Value: value}, &response)
require.True(t, response.Success)
var saved model.Option
require.NoError(t, db.Where(&model.Option{Key: key}).First(&saved).Error)
assert.Equal(t, value, saved.Value)
}
var savedOptions []model.Option
require.NoError(t, db.Find(&savedOptions).Error)
stored := map[string]string{}
for _, option := range savedOptions {
stored[option.Key] = option.Value
}
for range 2 {
operation_setting.GetQuotaSetting().TrustQuotaUSD = 10
operation_setting.GetQuotaSetting().PreConsumeMultiplier = 1
require.NoError(t, config.GlobalConfig.LoadFromDB(stored))
assert.Equal(t, 10.5, operation_setting.GetQuotaSetting().TrustQuotaUSD)
assert.Equal(t, 1.5, operation_setting.GetQuotaSetting().PreConsumeMultiplier)
}
for _, tc := range []struct{ key, value string }{
{"trust_quota_usd", "-1"}, {"trust_quota_usd", "NaN"}, {"trust_quota_usd", "+Inf"},
{"pre_consume_multiplier", "0"}, {"pre_consume_multiplier", "-0.5"},
{"pre_consume_multiplier", "NaN"}, {"pre_consume_multiplier", "Inf"},
{"pre_consume_multiplier", "1e309"}, {"pre_consume_multiplier", ""},
} {
key := "quota_setting." + tc.key
var response struct{ Success bool }
modelManagementRequest(t, UpdateOption, http.MethodPut, "/api/option/", OptionUpdateRequest{Key: key, Value: tc.value}, &response)
assert.False(t, response.Success, "%s=%q", key, tc.value)
var saved model.Option
require.NoError(t, db.Where(&model.Option{Key: key}).First(&saved).Error)
assert.Equal(t, stored[key], saved.Value, "invalid saves must preserve the previous value")
}
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
"billing_setting.billing_mode": `{"policy-model":"tiered_expr"}`,
"billing_setting.billing_expr": `{"policy-model":"tier(\"base\", p * 3 + c * 15)"}`,
"group_ratio_setting.group_ratio": `{"default":1}`,
}))
cases := []struct {
name string
threshold, multiplier float64
wallet, token int
force, unlimited bool
wantHeld int
}{
{"above threshold", 10, 1, 5500000, 5500000, false, false, 0},
{"custom threshold", 20, 1, 5500000, 5500000, false, false, 1500},
{"zero disables bypass", 0, 1, 5500000, 5500000, false, false, 1500},
{"wallet equals threshold", 10, 1, 5000000, 5500000, false, false, 1500},
{"token equals threshold", 10, 1, 5500000, 5000000, false, false, 1500},
{"fractional threshold", 10.5, 1, 5250001, 5250001, false, false, 0},
{"unlimited token", 10, 1, 5500000, 0, false, true, 0},
{"forced reservation", 10, 1, 5500000, 5500000, true, false, 1500},
{"half input cost", 0, 0.5, 100000, 100000, false, false, 750},
{"fractional multiple refunds excess", 0, 2.5, 100000, 100000, false, false, 3750},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.NoError(t, model.UpdateOption("quota_setting.trust_quota_usd", strconv.FormatFloat(tc.threshold, 'f', -1, 64)))
require.NoError(t, model.UpdateOption("quota_setting.pre_consume_multiplier", strconv.FormatFloat(tc.multiplier, 'f', -1, 64)))
user := model.User{Username: fmt.Sprintf("policy-user-%d", i), Quota: tc.wallet, Group: "default", AffCode: fmt.Sprintf("policy-aff-%d", i)}
require.NoError(t, db.Create(&user).Error)
token := model.Token{UserId: user.Id, Key: fmt.Sprintf("policy-token-%d", i), RemainQuota: tc.token, UnlimitedQuota: tc.unlimited}
require.NoError(t, db.Create(&token).Error)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
ctx.Set("token_quota", tc.token)
info := &relaycommon.RelayInfo{UserId: user.Id, TokenId: token.Id, TokenKey: token.Key, TokenUnlimited: tc.unlimited, ForcePreConsume: tc.force, OriginModelName: "policy-model", UserGroup: "default", UsingGroup: "default", BillingRequestInput: &billingexpr.RequestInput{}}
info.UserSetting.BillingPreference = "wallet_only"
price, err := helper.ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{MaxTokens: 10000})
require.NoError(t, err)
require.Nil(t, service.PreConsumeBilling(ctx, price.QuotaToPreConsume, info))
assert.Equal(t, tc.wantHeld, info.FinalPreConsumedQuota)
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, tc.wallet-tc.wantHeld, user.Quota)
if !tc.unlimited {
require.NoError(t, db.First(&token, token.Id).Error)
assert.Equal(t, tc.token-tc.wantHeld, token.RemainQuota)
}
_, actual, _ := service.TryTieredSettle(info, billingexpr.TokenParams{P: 1000, C: 100, Len: 1000})
assert.Equal(t, 2250, actual)
require.NoError(t, info.Billing.Settle(actual))
require.NoError(t, info.Billing.Settle(actual))
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, tc.wallet-actual, user.Quota)
if !tc.unlimited {
require.NoError(t, db.First(&token, token.Id).Error)
assert.Equal(t, tc.token-actual, token.RemainQuota)
}
})
}
})
}
}
func TestPreConsumeMultiplierRejectsInvalidRuntimeAndOverflow(t *testing.T) {
previous := config.GlobalConfig.ExportAllConfigs()
t.Cleanup(func() { require.NoError(t, config.GlobalConfig.LoadFromDB(previous)) })
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
"billing_setting.billing_mode": `{"policy-overflow":"tiered_expr"}`,
"billing_setting.billing_expr": `{"policy-overflow":"tier(\"base\", p * 3)"}`,
"group_ratio_setting.group_ratio": `{"default":1}`,
}))
for _, multiplier := range []float64{0, -0.5, math.NaN(), math.Inf(1), math.MaxFloat64} {
operation_setting.GetQuotaSetting().PreConsumeMultiplier = multiplier
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
info := &relaycommon.RelayInfo{OriginModelName: "policy-overflow", UserGroup: "default", UsingGroup: "default", BillingRequestInput: &billingexpr.RequestInput{}}
_, err := helper.ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
require.Error(t, err)
assert.Nil(t, info.Billing)
}
}
+84
View File
@@ -0,0 +1,84 @@
package controller
import (
"crypto/sha256"
"crypto/subtle"
"math"
"net/http"
"net/mail"
"os"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
)
// BotBalanceQuery is a dedicated read-only API; its key grants no dashboard access.
func BotBalanceQuery(c *gin.Context) {
c.Header("Cache-Control", "no-store")
key := os.Getenv("BOT_BALANCE_KEY")
raw, bearer := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
expected, supplied := sha256.Sum256([]byte(key)), sha256.Sum256([]byte(raw))
if len(key) < 32 || !bearer || subtle.ConstantTimeCompare(expected[:], supplied[:]) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "code": "unauthorized"})
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 8192)
var request struct {
Email string `json:"email"`
IDs []int `json:"ids"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "invalid_request"})
return
}
email := model.NormalizeEmail(request.Email)
if (email == "") == (len(request.IDs) == 0) || len(request.IDs) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "invalid_request"})
return
}
if email != "" {
parsed, err := mail.ParseAddress(email)
if err != nil || parsed.Address != email || len(email) > 50 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "invalid_email"})
return
}
}
for _, id := range request.IDs {
if id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "invalid_request"})
return
}
}
accounts, err := model.FindBotBalanceAccounts(email, request.IDs)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"success": false, "code": "query_failed"})
return
}
if email != "" && len(accounts) != 1 {
code := "not_found"
if len(accounts) > 1 {
code = "ambiguous_email"
}
c.JSON(http.StatusConflict, gin.H{"success": false, "code": code})
return
}
unit, rate := common.QuotaPerUnit, operation_setting.USDExchangeRate
if unit <= 0 || rate <= 0 || math.IsNaN(unit) || math.IsNaN(rate) || math.IsInf(unit, 0) || math.IsInf(rate, 0) {
c.JSON(http.StatusServiceUnavailable, gin.H{"success": false, "code": "invalid_currency"})
return
}
items := make([]gin.H, 0, len(accounts))
for _, account := range accounts {
active := account.Status == common.UserStatusEnabled
item := gin.H{"id": account.ID, "active": active}
if active {
item["balance_cny"] = decimal.NewFromInt(int64(account.Quota)).Mul(decimal.NewFromFloat(rate)).DivRound(decimal.NewFromFloat(unit), 18).String()
}
items = append(items, item)
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": items})
}
+113
View File
@@ -0,0 +1,113 @@
package controller
import (
"fmt"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestBotBalanceQuery(t *testing.T) {
key := strings.Repeat("b", 48)
t.Setenv("BOT_BALANCE_KEY", key)
oldUnit, oldRate := common.QuotaPerUnit, operation_setting.USDExchangeRate
common.QuotaPerUnit, operation_setting.USDExchangeRate = 500000, 7.3
t.Cleanup(func() { common.QuotaPerUnit, operation_setting.USDExchangeRate = oldUnit, oldRate })
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
t.Run(dialect, func(t *testing.T) {
var driver gorm.Dialector = sqlite.Open(":memory:")
if dialect == "mysql" {
require.NotEmpty(t, os.Getenv("TEST_MYSQL_DSN"))
driver = mysql.Open(os.Getenv("TEST_MYSQL_DSN"))
}
if dialect == "postgres" {
require.NotEmpty(t, os.Getenv("TEST_POSTGRES_DSN"))
driver = postgres.Open(os.Getenv("TEST_POSTGRES_DSN"))
}
db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: fmt.Sprintf("bot_%d_", time.Now().UnixNano())}})
require.NoError(t, err)
conn, err := db.DB()
require.NoError(t, err)
conn.SetMaxOpenConns(1)
oldDB := model.DB
model.DB = db
t.Cleanup(func() { require.NoError(t, db.Migrator().DropTable(&model.User{})); model.DB = oldDB; _ = conn.Close() })
require.NoError(t, db.AutoMigrate(&model.User{}))
rows := []model.User{
{Id: 1, Username: "bot1", Email: "a@example.com", Status: 1, Quota: 250000},
{Id: 2, Username: "bot2", Email: "dup@example.com", Status: 1, Quota: 0},
{Id: 3, Username: "bot3", Email: "dup@example.com", Status: 1, Quota: -500000},
{Id: 4, Username: "bot4", Email: "disabled@example.com", Status: 2, Quota: 900000},
{Id: 5, Username: "bot5", Email: "gone@example.com", Status: 1},
}
for i := range rows {
rows[i].AffCode = fmt.Sprintf("bot%d", i)
}
require.NoError(t, db.Create(&rows).Error)
require.NoError(t, db.Delete(&rows[4]).Error)
r := gin.New()
r.POST("/api/bot/balances", BotBalanceQuery)
for _, tc := range []struct {
name, body, token string
status int
code string
}{
{"missing key", `{}`, "", 401, "unauthorized"},
{"wrong key", `{}`, "Bearer wrong", 401, "unauthorized"},
{"empty", `{}`, "Bearer " + key, 400, "invalid_request"},
{"both", `{"email":"a@example.com","ids":[1]}`, "Bearer " + key, 400, "invalid_request"},
{"bad id", `{"ids":[0]}`, "Bearer " + key, 400, "invalid_request"},
{"bad email", `{"email":"invalid"}`, "Bearer " + key, 400, "invalid_email"},
{"deleted", `{"email":"gone@example.com"}`, "Bearer " + key, 409, "not_found"},
{"duplicate", `{"email":"dup@example.com"}`, "Bearer " + key, 409, "ambiguous_email"},
{"exact not substring", `{"email":"aa@example.com"}`, "Bearer " + key, 409, "not_found"},
{"normalized", `{"email":" A@EXAMPLE.COM "}`, "Bearer " + key, 200, ""},
{"batch", `{"ids":[1,2,3,4,5,999]}`, "Bearer " + key, 200, ""},
} {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/bot/balances", strings.NewReader(tc.body))
req.Header.Set("Authorization", tc.token)
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
require.Equal(t, tc.status, w.Code, w.Body.String())
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
assert.Equal(t, tc.code, gjson.Get(w.Body.String(), "code").String())
if tc.status == 200 {
assert.NotContains(t, w.Body.String(), "email")
assert.NotContains(t, w.Body.String(), "quota")
assert.NotContains(t, w.Body.String(), "password")
assert.Equal(t, "3.65", gjson.Get(w.Body.String(), "data.0.balance_cny").String())
if tc.name == "batch" {
assert.Equal(t, int64(4), gjson.Get(w.Body.String(), "data.#").Int())
assert.Equal(t, "0", gjson.Get(w.Body.String(), "data.1.balance_cny").String())
assert.Equal(t, "-7.3", gjson.Get(w.Body.String(), "data.2.balance_cny").String())
assert.False(t, gjson.Get(w.Body.String(), "data.3.active").Bool())
assert.False(t, gjson.Get(w.Body.String(), "data.3.balance_cny").Exists())
}
}
})
}
t.Setenv("BOT_BALANCE_KEY", "")
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("POST", "/api/bot/balances", strings.NewReader(`{}`)))
assert.Equal(t, 401, w.Code)
t.Setenv("BOT_BALANCE_KEY", key)
})
}
}
+156
View File
@@ -0,0 +1,156 @@
package controller
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"net/http"
"os"
"slices"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var chainEnqueue sync.Mutex
var chainKinds = []string{"fingerprint", "ciallo", "vision"}
// All three child tasks are committed together, using the existing durable ledger.
func enqueueChainTest(ctx context.Context, group string, channelID int) ([]model.DetectionRun, error) {
chainEnqueue.Lock()
defer chainEnqueue.Unlock()
var runs []model.DetectionRun
err := model.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var active []model.DetectionRun
if err := tx.Where("group_name = ? AND request_key LIKE ? AND settled = ? AND deadline > ?", group, "chain:%", false, time.Now().Unix()).Order("created_at asc").Limit(1).Find(&active).Error; err != nil {
return err
}
if len(active) > 0 {
id, _, _ := strings.Cut(active[0].ID, "_")
return tx.Where("id IN ?", []string{id + "_fingerprint", id + "_ciallo", id + "_vision"}).Find(&runs).Error
}
id, err := common.GenerateRandomCharsKey(32)
if err != nil {
return err
}
now := time.Now().Unix()
for _, kind := range chainKinds {
runs = append(runs, model.DetectionRun{ID: id + "_" + kind, RequestKey: "chain:" + id + ":" + kind, ChannelID: channelID, GroupName: group, Kind: kind, Status: "pending", CreatedAt: now, Deadline: now + 600})
}
return tx.Create(&runs).Error
})
return runs, err
}
func BotChainTest(c *gin.Context) {
c.Header("Cache-Control", "no-store")
key := os.Getenv("BOT_BALANCE_KEY")
derived := sha256.Sum256([]byte("newapi-chaintest-v1:" + key))
raw, bearer := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
expected, supplied := sha256.Sum256([]byte(hex.EncodeToString(derived[:]))), sha256.Sum256([]byte(raw))
if len(key) < 32 || !bearer || subtle.ConstantTimeCompare(expected[:], supplied[:]) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "code": "unauthorized"})
return
}
var runs []model.DetectionRun
if c.Request.Method == http.MethodPost {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1024)
var input struct {
Group string `json:"group"`
}
if c.ShouldBindJSON(&input) != nil || strings.TrimSpace(input.Group) == "" || len(input.Group) > 128 {
c.JSON(400, gin.H{"success": false, "code": "invalid_group"})
return
}
input.Group = strings.TrimSpace(input.Group)
channels, err := service.AstraSamplingChannels(c.Request.Context())
if err != nil {
c.JSON(503, gin.H{"success": false, "code": "unavailable"})
return
}
slices.SortFunc(channels, func(a, b model.Channel) int { return a.Id - b.Id })
selected := 0
for _, channel := range channels {
if slices.Contains(channel.GetGroups(), input.Group) {
selected = channel.Id
break
}
}
if selected == 0 {
c.JSON(404, gin.H{"success": false, "code": "group_unavailable"})
return
}
runs, err = enqueueChainTest(c.Request.Context(), input.Group, selected)
if err != nil {
c.JSON(503, gin.H{"success": false, "code": "unavailable"})
return
}
for _, run := range runs {
go processDetection(context.Background(), run)
}
} else {
id := c.Param("id")
if len(id) != 32 || strings.ContainsAny(id, "_%") {
c.JSON(404, gin.H{"success": false, "code": "not_found"})
return
}
err := model.DB.WithContext(c.Request.Context()).Where("id IN ? AND request_key LIKE ?", []string{id + "_fingerprint", id + "_ciallo", id + "_vision"}, "chain:"+id+":%").Find(&runs).Error
if err != nil {
c.JSON(503, gin.H{"success": false, "code": "unavailable"})
return
}
}
data, err := chainTestResult(runs, time.Now().Unix())
if err != nil {
c.JSON(404, gin.H{"success": false, "code": "not_found"})
return
}
c.JSON(200, gin.H{"success": true, "data": data})
}
func chainTestResult(runs []model.DetectionRun, now int64) (gin.H, error) {
if len(runs) != 3 {
return nil, errors.New("incomplete chain")
}
id, _, _ := strings.Cut(runs[0].ID, "_")
results := make(map[string]any, 3)
done := true
for _, run := range runs {
run = service.NormalizeVisionRun(run)
state, text, match := "running", "", ""
if run.Settled {
state = "failed"
var result fingerprint.Result
if run.Status == "succeeded" && common.UnmarshalJsonStr(run.Result, &result) == nil {
text = result.Closest
if run.Kind == "fingerprint" {
match = service.AstraSamplingResultStatus(result)
state = match
if state != "match" && state != "mismatch" && state != "inconclusive" {
state = "failed"
}
} else if strings.TrimSpace(text) != "" {
state = service.VisionQuality(text)
if run.Kind == "ciallo" {
state = service.CialloQuality(text)
}
}
}
} else if now >= run.Deadline {
state = "failed"
} else {
done = false
}
results[run.Kind] = gin.H{"status": state, "text": text}
}
return gin.H{"id": id, "group": runs[0].GroupName, "deadline": runs[0].Deadline, "done": done, "results": results}, nil
}
+121 -11
View File
@@ -157,7 +157,8 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He
for k := range headers { for k := range headers {
req.Header.Add(k, headers.Get(k)) req.Header.Add(k, headers.Get(k))
} }
client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy) setting := channel.GetSetting()
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -420,6 +421,113 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
return availableBalanceUsd, nil return availableBalanceUsd, nil
} }
func updateChannelSub2APIBalance(channel *model.Channel) (float64, error) {
baseURL := channel.GetBaseURL()
if baseURL == "" {
return 0, errors.New("Sub2API channel is missing base_url")
}
url := fmt.Sprintf("%s/v1/usage", strings.TrimRight(baseURL, "/"))
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
type sub2apiUsageResponse struct {
Balance *float64 `json:"balance"`
Remaining *float64 `json:"remaining"`
IsValid *bool `json:"isValid"`
Subscription *struct {
DailyLimitUSD *float64 `json:"daily_limit_usd"`
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
} `json:"subscription"`
}
usage := sub2apiUsageResponse{}
if err := common.Unmarshal(body, &usage); err != nil {
return 0, err
}
if usage.IsValid != nil && !*usage.IsValid {
return 0, errors.New("Sub2API key is invalid (isValid=false)")
}
balance := usage.Remaining
if balance == nil {
balance = usage.Balance
}
if balance == nil {
return 0, errors.New("Sub2API /v1/usage response is missing remaining and balance fields (the key may belong to a subscription group without an available balance)")
}
// Sub2API reports remaining=-1 for a subscription group with no daily, weekly,
// or monthly limit. A wallet balance can legitimately be negative, so detect
// the unlimited case from the subscription limits rather than the value.
subscription := usage.Subscription
if subscription != nil && subscription.DailyLimitUSD == nil && subscription.WeeklyLimitUSD == nil && subscription.MonthlyLimitUSD == nil {
return 0, errors.New("Sub2API /v1/usage reports an unlimited subscription without a finite balance")
}
channel.UpdateBalance(*balance)
return *balance, nil
}
// updateChannelNewAPIBalance reads the upstream key's remaining quota. When the
// upstream also returns its owner's quota, the smaller of the two is what the
// key can still spend. Quota converts to USD with the upstream's quota_per_unit.
func updateChannelNewAPIBalance(channel *model.Channel) (float64, error) {
baseURL := strings.TrimRight(channel.GetBaseURL(), "/")
if baseURL == "" {
return 0, errors.New("New API channel is missing base_url")
}
body, err := GetResponseBody("GET", baseURL+"/api/usage/token/", channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
var usage struct {
Code bool `json:"code"`
Message string `json:"message"`
Data struct {
TotalAvailable float64 `json:"total_available"`
UnlimitedQuota bool `json:"unlimited_quota"`
UserQuota *struct {
TotalAvailable float64 `json:"total_available"`
} `json:"user_quota"`
} `json:"data"`
}
if err := common.Unmarshal(body, &usage); err != nil {
return 0, err
}
if !usage.Code {
return 0, fmt.Errorf("New API usage query failed: %s", usage.Message)
}
remaining := usage.Data.TotalAvailable
if usage.Data.UnlimitedQuota {
remaining = math.Inf(1)
}
if userQuota := usage.Data.UserQuota; userQuota != nil {
remaining = min(remaining, userQuota.TotalAvailable)
}
if math.IsInf(remaining, 1) {
return 0, common.NewMessage(`The upstream key has unlimited quota. Turn on "Allow querying user quota" for this key on the upstream to see the real balance.`)
}
body, err = GetResponseBody("GET", baseURL+"/api/status", channel, nil)
if err != nil {
return 0, err
}
var status struct {
Data struct {
QuotaPerUnit float64 `json:"quota_per_unit"`
} `json:"data"`
}
if err := common.Unmarshal(body, &status); err != nil {
return 0, err
}
if status.Data.QuotaPerUnit <= 0 {
return 0, errors.New("New API /api/status response is missing quota_per_unit")
}
balance := remaining / status.Data.QuotaPerUnit
channel.UpdateBalance(balance)
return balance, nil
}
func fetchAdvancedCustomBalance(channel *model.Channel) (channelBalanceResult, error) { func fetchAdvancedCustomBalance(channel *model.Channel) (channelBalanceResult, error) {
key := strings.TrimSpace(channel.Key) key := strings.TrimSpace(channel.Key)
info := &relaycommon.RelayInfo{ info := &relaycommon.RelayInfo{
@@ -453,7 +561,8 @@ func fetchAdvancedCustomBalance(channel *model.Channel) (channelBalanceResult, e
request.Host = headers.Get(name) request.Host = headers.Get(name)
} }
} }
client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy) setting := channel.GetSetting()
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil { if err != nil {
return channelBalanceResult{}, sanitizeFetchModelsError(err, key) return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
} }
@@ -524,7 +633,7 @@ func updateStandardChannelBalance(channel *model.Channel) (float64, error) {
baseURL = channel.GetBaseURL() baseURL = channel.GetBaseURL()
} }
case constant.ChannelTypeAzure: case constant.ChannelTypeAzure:
return 0, errors.New("尚未实现") return 0, common.NewMessage("Not implemented yet")
case constant.ChannelTypeCustom: case constant.ChannelTypeCustom:
baseURL = channel.GetBaseURL() baseURL = channel.GetBaseURL()
//case common.ChannelTypeOpenAISB: //case common.ChannelTypeOpenAISB:
@@ -543,8 +652,12 @@ func updateStandardChannelBalance(channel *model.Channel) (float64, error) {
return updateChannelOpenRouterBalance(channel) return updateChannelOpenRouterBalance(channel)
case constant.ChannelTypeMoonshot: case constant.ChannelTypeMoonshot:
return updateChannelMoonshotBalance(channel) return updateChannelMoonshotBalance(channel)
case constant.ChannelTypeSub2API:
return updateChannelSub2APIBalance(channel)
case constant.ChannelTypeNewAPI:
return updateChannelNewAPIBalance(channel)
default: default:
return 0, errors.New("尚未实现") return 0, common.NewMessage("Not implemented yet")
} }
url := fmt.Sprintf("%s/v1/dashboard/billing/subscription", baseURL) url := fmt.Sprintf("%s/v1/dashboard/billing/subscription", baseURL)
@@ -594,10 +707,7 @@ func UpdateChannelBalance(c *gin.Context) {
return return
} }
if channel.ChannelInfo.IsMultiKey { if channel.ChannelInfo.IsMultiKey {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Multi-key channels do not support balance queries")
"success": false,
"message": "多密钥渠道不支持余额查询",
})
return return
} }
result, err := updateChannelBalance(channel) result, err := updateChannelBalance(channel)
@@ -639,7 +749,7 @@ func updateAllChannelsBalance() error {
} else if result.RawResponse == "" { } else if result.RawResponse == "" {
// err is nil & balance <= 0 means quota is used up // err is nil & balance <= 0 means quota is used up
if result.Balance <= 0 { if result.Balance <= 0 {
service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "余额不足") service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "Insufficient balance")
} }
} }
time.Sleep(common.RequestInterval) time.Sleep(common.RequestInterval)
@@ -664,8 +774,8 @@ func UpdateAllChannelsBalance(c *gin.Context) {
func AutomaticallyUpdateChannels(frequency int) { func AutomaticallyUpdateChannels(frequency int) {
for { for {
time.Sleep(time.Duration(frequency) * time.Minute) time.Sleep(time.Duration(frequency) * time.Minute)
common.SysLog("updating all channels") common.SysLog(common.LogText("updating all channels"))
_ = updateAllChannelsBalance() _ = updateAllChannelsBalance()
common.SysLog("channels update done") common.SysLog(common.LogText("channels update done"))
} }
} }
+24 -18
View File
@@ -16,6 +16,7 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr" "github.com/QuantumNous/new-api/pkg/billingexpr"
@@ -293,7 +294,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
//// 创建一个用于日志的 info 副本,移除 ApiKey //// 创建一个用于日志的 info 副本,移除 ApiKey
//logInfo := info //logInfo := info
//logInfo.ApiKey = "" //logInfo.ApiKey = ""
common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, info.ToString())) common.SysLog(common.LogText("testing channel %d with model %s , info %+v ", channel.Id, testModel, info.ToString()))
priceData, err := helper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta()) priceData, err := helper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta())
if err != nil { if err != nil {
@@ -447,7 +448,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
httpResp = resp.(*http.Response) httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK { if httpResp.StatusCode != http.StatusOK {
err := service.RelayErrorHandler(c.Request.Context(), httpResp, true) err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
common.SysError(fmt.Sprintf( common.SysError(common.LogText(
"channel test bad response: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v", "channel test bad response: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v",
channel.Id, channel.Id,
channel.Name, channel.Name,
@@ -508,15 +509,15 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
PromptTokens: usage.PromptTokens, PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens, CompletionTokens: usage.CompletionTokens,
ModelName: info.OriginModelName, ModelName: info.OriginModelName,
TokenName: "模型测试", TokenName: "Model test",
Quota: quota, Quota: quota,
Content: "模型测试", Content: []*common.Message{common.NewMessage("Model test")},
UseTimeSeconds: int(consumedTime), UseTimeSeconds: int(consumedTime),
IsStream: info.IsStream, IsStream: info.IsStream,
Group: info.UsingGroup, Group: info.UsingGroup,
Other: other, Other: other,
}) })
common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody))) common.SysLog(common.LogText("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
return testResult{ return testResult{
context: c, context: c,
localErr: nil, localErr: nil,
@@ -938,7 +939,12 @@ func testChannelForHealthCheck(ctx context.Context, channel *model.Channel, test
if common.AutomaticDisableChannelEnabled && !shouldBanChannel { if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
if milliseconds > disableThreshold { if milliseconds > disableThreshold {
err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0) // The reason is stored with the channel and sent in the disable
// notice; no request states a language here.
err := errors.New(i18n.Translate("", i18n.MsgChannelResponseTimeExceeded, map[string]any{
"Seconds": fmt.Sprintf("%.2f", float64(milliseconds)/1000.0),
"Threshold": fmt.Sprintf("%.2f", float64(disableThreshold)/1000.0),
}))
newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout) newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
shouldBanChannel = true shouldBanChannel = true
} }
@@ -1085,8 +1091,9 @@ func performChannelTests(ctx context.Context, channels []*model.Channel, testUse
// stops promptly. mode selects the channel set: an empty mode falls back to the // stops promptly. mode selects the channel set: an empty mode falls back to the
// configured monitor ChannelTestMode (scheduled behavior), while a manual // configured monitor ChannelTestMode (scheduled behavior), while a manual
// trigger passes ChannelTestModeScheduledAll to test every channel. When notify // trigger passes ChannelTestModeScheduledAll to test every channel. When notify
// is set the root user is notified on completion. Cross-instance execution is // is set, the administrators who receive channel notices are notified on
// guarded by the system task per-type lock, so no process-local guard is needed. // 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) { func runChannelTestTask(ctx context.Context, mode string, notify bool, report func(processed, total int)) (channelTestSummary, error) {
testUserID, err := resolveChannelTestUserID(nil) testUserID, err := resolveChannelTestUserID(nil)
if err != nil { if err != nil {
@@ -1104,7 +1111,9 @@ func runChannelTestTask(ctx context.Context, mode string, notify bool, report fu
concurrency := operation_setting.GetMonitorSetting().ChannelTestConcurrency concurrency := operation_setting.GetMonitorSetting().ChannelTestConcurrency
summary := performChannelTests(ctx, selected, testUserID, allowDisable, concurrency, report) summary := performChannelTests(ctx, selected, testUserID, allowDisable, concurrency, report)
if notify && (ctx == nil || ctx.Err() == nil) { if notify && (ctx == nil || ctx.Err() == nil) {
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成") service.NotifyChannelWatchers(dto.NotifyTypeChannelTest, func(lang string) (string, string) {
return i18n.Translate(lang, i18n.MsgChannelTestCompletedSubject), i18n.Translate(lang, i18n.MsgChannelTestCompletedContent)
})
} }
return summary, nil return summary, nil
} }
@@ -1139,15 +1148,12 @@ func TestAllChannels(c *gin.Context) {
return return
} }
if !created { if !created {
c.JSON(http.StatusConflict, gin.H{ msg := common.NewMessage("A channel test task is already running or queued. Cannot start this manual task")
"success": false, common.ApiErrorStatus(c, http.StatusConflict, msg, gin.H{"data": gin.H{
"message": "已有通道测试任务正在运行或等待中,不能启动本次手动任务", "task_id": task.TaskID,
"data": gin.H{ "status": task.Status,
"task_id": task.TaskID, "type": task.Type,
"status": task.Status, }})
"type": task.Type,
},
})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
+142 -214
View File
@@ -6,13 +6,13 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin" "github.com/QuantumNous/new-api/pkg/jsplugin"
relaychannel "github.com/QuantumNous/new-api/relay/channel" relaychannel "github.com/QuantumNous/new-api/relay/channel"
@@ -212,14 +212,14 @@ func GetAllChannels(c *gin.Context) {
if enableTagMode { if enableTagMode {
tags, err := model.GetPaginatedChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter), pageInfo.GetStartIdx(), pageInfo.GetPageSize()) tags, err := model.GetPaginatedChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter), pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil { if err != nil {
common.SysError("failed to get paginated tags: " + err.Error()) common.SysError(common.LogText("failed to get paginated tags: %s", err.Error()))
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签失败,请稍后重试"}) common.ApiErrorT(c, "Failed to fetch tags. Please try again later")
return return
} }
total, err = model.CountChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter)) total, err = model.CountChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter))
if err != nil { if err != nil {
common.SysError("failed to count tags: " + err.Error()) common.SysError(common.LogText("failed to count tags: %s", err.Error()))
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签数量失败,请稍后重试"}) common.ApiErrorT(c, "Failed to count tags. Please try again later")
return return
} }
for _, tag := range tags { for _, tag := range tags {
@@ -231,16 +231,16 @@ func GetAllChannels(c *gin.Context) {
Omit("key"). Omit("key").
Find(&tagChannels).Error Find(&tagChannels).Error
if err != nil { if err != nil {
common.SysError("failed to get channels by tag: " + err.Error()) common.SysError(common.LogText("failed to get channels by tag: %s", err.Error()))
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签渠道失败,请稍后重试"}) common.ApiErrorT(c, "Failed to fetch channels with this tag. Please try again later")
return return
} }
channelData = append(channelData, tagChannels...) channelData = append(channelData, tagChannels...)
} }
} else { } else {
if err := buildChannelListQuery(groupFilter, statusFilter, typeFilter).Count(&total).Error; err != nil { if err := buildChannelListQuery(groupFilter, statusFilter, typeFilter).Count(&total).Error; err != nil {
common.SysError("failed to count channels: " + err.Error()) common.SysError(common.LogText("failed to count channels: %s", err.Error()))
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道数量失败,请稍后重试"}) common.ApiErrorT(c, "Failed to count channels. Please try again later")
return return
} }
@@ -250,8 +250,8 @@ func GetAllChannels(c *gin.Context) {
Omit("key"). Omit("key").
Find(&channelData).Error Find(&channelData).Error
if err != nil { if err != nil {
common.SysError("failed to get channels: " + err.Error()) common.SysError(common.LogText("failed to get channels: %s", err.Error()))
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道列表失败,请稍后重试"}) common.ApiErrorT(c, "Failed to fetch channels. Please try again later")
return return
} }
} }
@@ -266,8 +266,8 @@ func GetAllChannels(c *gin.Context) {
Count int64 Count int64
} }
if err := countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error; err != nil { if err := countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error; err != nil {
common.SysError("failed to count channel types: " + err.Error()) common.SysError(common.LogText("failed to count channel types: %s", err.Error()))
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道类型统计失败,请稍后重试"}) common.ApiErrorT(c, "Failed to count channel types. Please try again later")
return return
} }
typeCounts := make(map[int64]int64) typeCounts := make(map[int64]int64)
@@ -333,10 +333,7 @@ func FetchUpstreamModels(c *gin.Context) {
ids, err := fetchChannelUpstreamModelIDs(channel) ids, err := fetchChannelUpstreamModelIDs(channel)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Failed to fetch models: {{error}}", map[string]any{"error": err.Error()})
"success": false,
"message": fmt.Sprintf("获取模型列表失败: %s", err.Error()),
})
return return
} }
@@ -376,10 +373,7 @@ func SearchChannels(c *gin.Context) {
if enableTagMode { if enableTagMode {
tags, err := model.SearchTags(keyword, group, modelKeyword, idSort) tags, err := model.SearchTags(keyword, group, modelKeyword, idSort)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiError(c, err)
"success": false,
"message": err.Error(),
})
return return
} }
for _, tag := range tags { for _, tag := range tags {
@@ -389,10 +383,7 @@ func SearchChannels(c *gin.Context) {
Omit("key"). Omit("key").
Find(&tagChannels).Error Find(&tagChannels).Error
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiError(c, err)
"success": false,
"message": err.Error(),
})
return return
} }
channelData = append(channelData, tagChannels...) channelData = append(channelData, tagChannels...)
@@ -401,10 +392,7 @@ func SearchChannels(c *gin.Context) {
} else { } else {
channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort, sortOptions) channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort, sortOptions)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiError(c, err)
"success": false,
"message": err.Error(),
})
return return
} }
channelData = channels channelData = channels
@@ -506,14 +494,14 @@ func GetChannel(c *gin.Context) {
func GetChannelKey(c *gin.Context) { func GetChannelKey(c *gin.Context) {
channelId, err := strconv.Atoi(c.Param("id")) channelId, err := strconv.Atoi(c.Param("id"))
if err != nil || channelId <= 0 { if err != nil || channelId <= 0 {
common.ApiErrorMsg(c, "渠道ID格式错误") common.ApiErrorT(c, "Invalid channel ID")
return return
} }
// 获取渠道信息(包含密钥) // 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true) channel, err := model.GetChannelById(channelId, true)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiErrorI18n(c, i18n.MsgChannelNotExists) common.ApiErrorT(c, "Channel does not exist")
return return
} }
if err != nil { if err != nil {
@@ -528,15 +516,15 @@ func GetChannelKey(c *gin.Context) {
}) })
// 返回渠道密钥 // 返回渠道密钥
c.JSON(http.StatusOK, gin.H{ common.ApiSuccessT(c, "Fetched successfully", map[string]any{
"success": true, "key": channel.Key,
"message": "获取成功",
"data": map[string]any{
"key": channel.Key,
},
}) })
} }
// maxTaskExtendPluginKeys bounds the plugins one New API channel can be
// extended with; it comfortably covers every built-in and installed plugin.
const maxTaskExtendPluginKeys = 32
// validateChannel 通用的渠道校验函数 // validateChannel 通用的渠道校验函数
func validateChannel(channel *model.Channel, isAdd bool) error { func validateChannel(channel *model.Channel, isAdd bool) error {
if channel == nil { if channel == nil {
@@ -545,7 +533,7 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
// 校验 channel settings // 校验 channel settings
if err := channel.ValidateSettings(); err != nil { if err := channel.ValidateSettings(); err != nil {
return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error()) return common.NewMessage("Invalid channel settings format: {{error}}", map[string]any{"error": err.Error()})
} }
if channel.Type == constant.ChannelTypeTaskPlugin { if channel.Type == constant.ChannelTypeTaskPlugin {
pluginKey := strings.TrimSpace(channel.GetSetting().TaskPluginKey) pluginKey := strings.TrimSpace(channel.GetSetting().TaskPluginKey)
@@ -571,6 +559,42 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
} }
} }
setting := channel.GetSetting()
if channel.Type != constant.ChannelTypeNewAPI && len(setting.TaskExtendPluginKeys) > 0 {
return fmt.Errorf("task_extend_plugin_keys is only supported on New API channels")
}
if channel.Type == constant.ChannelTypeNewAPI {
if len(setting.TaskExtendPluginKeys) > maxTaskExtendPluginKeys {
return fmt.Errorf("task_extend_plugin_keys must not exceed %d plugins", maxTaskExtendPluginKeys)
}
// The single key stays valid on a New API channel, so both bindings are
// checked as one set.
keys := setting.TaskExtendPluginKeys
if setting.TaskPluginKey != "" {
keys = append([]string{setting.TaskPluginKey}, keys...)
}
bound := make(map[string]struct{}, len(keys))
for _, key := range keys {
if key == "" || key != strings.TrimSpace(key) {
return fmt.Errorf("task plugin key %q is invalid", key)
}
if len(key) > 30 {
return fmt.Errorf("task plugin key must not exceed 30 characters")
}
if _, duplicate := bound[key]; duplicate {
return fmt.Errorf("task plugin %q is bound more than once", key)
}
plugin, ok := jsplugin.DefaultRegistry.Get(key)
if !ok {
return fmt.Errorf("task plugin %q is not registered", key)
}
if !plugin.Meta.SupportsUpstream(jsplugin.UpstreamKindNewAPI) {
return fmt.Errorf("task plugin %q does not support a New API upstream and cannot be bound to a New API channel", key)
}
bound[key] = struct{}{}
}
}
if channel.Type == constant.ChannelTypeNewAPI && strings.TrimSpace(channel.GetBaseURL()) == "" { if channel.Type == constant.ChannelTypeNewAPI && strings.TrimSpace(channel.GetBaseURL()) == "" {
return fmt.Errorf("New API channel base URL cannot be empty") return fmt.Errorf("New API channel base URL cannot be empty")
} }
@@ -590,7 +614,7 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
// 检查模型名称长度是否超过 255 // 检查模型名称长度是否超过 255
for _, m := range channel.GetModels() { for _, m := range channel.GetModels() {
if len(m) > 255 { if len(m) > 255 {
return fmt.Errorf("模型名称过长: %s", m) return common.NewMessage("Model name is too long: {{model}}", map[string]any{"model": m})
} }
} }
} }
@@ -598,16 +622,16 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
// VertexAI 特殊校验 // VertexAI 特殊校验
if channel.Type == constant.ChannelTypeVertexAi { if channel.Type == constant.ChannelTypeVertexAi {
if channel.Other == "" { if channel.Other == "" {
return fmt.Errorf("部署地区不能为空") return common.NewMessage("Deployment region cannot be empty")
} }
regionMap, err := common.StrToMap(channel.Other) regionMap, err := common.StrToMap(channel.Other)
if err != nil { if err != nil {
return fmt.Errorf("部署地区必须是标准的Json格式,例如{\"default\": \"us-central1\", \"region2\": \"us-east1\"}") return common.NewMessage("Deployment region must be valid JSON, for example {{example}}", map[string]any{"example": `{"default": "us-central1", "region2": "us-east1"}`})
} }
if regionMap["default"] == nil { if regionMap["default"] == nil {
return fmt.Errorf("部署地区必须包含default字段") return common.NewMessage("Deployment region must include a default field")
} }
} }
@@ -646,8 +670,8 @@ func RefreshCodexChannelCredential(c *gin.Context) {
oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true}) oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true})
if err != nil { if err != nil {
common.SysError("failed to refresh codex channel credential: " + err.Error()) common.SysError(common.LogText("failed to refresh codex channel credential: %s", err.Error()))
c.JSON(http.StatusOK, gin.H{"success": false, "message": "刷新凭证失败,请稍后重试"}) common.ApiErrorT(c, "Failed to refresh credentials. Please try again later")
return return
} }
@@ -680,7 +704,7 @@ func getVertexArrayKeys(keys string) ([]string, error) {
var keyArray []any var keyArray []any
err := common.Unmarshal([]byte(keys), &keyArray) err := common.Unmarshal([]byte(keys), &keyArray)
if err != nil { if err != nil {
return nil, fmt.Errorf("批量添加 Vertex AI 必须使用标准的JsonArray格式,例如[{key1}, {key2}...],请检查输入: %w", err) return nil, common.NewMessage("Batch-adding Vertex AI keys requires a JSON array, for example {{example}}. Check the input: {{error}}", map[string]any{"example": "[{key1}, {key2}...]", "error": err.Error()})
} }
cleanKeys := make([]string, 0, len(keyArray)) cleanKeys := make([]string, 0, len(keyArray))
for _, key := range keyArray { for _, key := range keyArray {
@@ -689,9 +713,9 @@ func getVertexArrayKeys(keys string) ([]string, error) {
case string: case string:
keyStr = strings.TrimSpace(v) keyStr = strings.TrimSpace(v)
default: default:
bytes, err := json.Marshal(v) bytes, err := common.Marshal(v)
if err != nil { if err != nil {
return nil, fmt.Errorf("Vertex AI key JSON 编码失败: %w", err) return nil, common.NewMessage("Failed to encode Vertex AI key JSON: {{error}}", map[string]any{"error": err.Error()})
} }
keyStr = string(bytes) keyStr = string(bytes)
} }
@@ -700,7 +724,7 @@ func getVertexArrayKeys(keys string) ([]string, error) {
} }
} }
if len(cleanKeys) == 0 { if len(cleanKeys) == 0 {
return nil, fmt.Errorf("批量添加 Vertex AI 的 keys 不能为空") return nil, common.NewMessage("Vertex AI keys for batch adding cannot be empty")
} }
return cleanKeys, nil return cleanKeys, nil
} }
@@ -713,7 +737,10 @@ func AddChannel(c *gin.Context) {
return return
} }
if addChannelRequest.Channel != nil && addChannelRequest.Channel.Type == constant.ChannelTypeTaskPlugin && // Binding a plugin needs the same permission on a New API channel as the
// type-61 channel dedicated to it.
if addChannelRequest.Channel != nil &&
(addChannelRequest.Channel.Type == constant.ChannelTypeTaskPlugin || len(addChannelRequest.Channel.GetSetting().TaskPluginBindings()) > 0) &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) { !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
@@ -727,10 +754,7 @@ func AddChannel(c *gin.Context) {
(addChannelRequest.Channel.BaseURL == nil || strings.TrimSpace(*addChannelRequest.Channel.BaseURL) == "") (addChannelRequest.Channel.BaseURL == nil || strings.TrimSpace(*addChannelRequest.Channel.BaseURL) == "")
// 使用统一的校验函数 // 使用统一的校验函数
if err := validateChannel(addChannelRequest.Channel, true); err != nil { if err := validateChannel(addChannelRequest.Channel, true); err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiError(c, err)
"success": false,
"message": err.Error(),
})
return return
} }
@@ -743,10 +767,7 @@ func AddChannel(c *gin.Context) {
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
array, err := getVertexArrayKeys(addChannelRequest.Channel.Key) array, err := getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiError(c, err)
"success": false,
"message": err.Error(),
})
return return
} }
addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(array) addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(array)
@@ -769,10 +790,7 @@ func AddChannel(c *gin.Context) {
// multi json // multi json
keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key) keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiError(c, err)
"success": false,
"message": err.Error(),
})
return return
} }
} else { } else {
@@ -781,10 +799,7 @@ func AddChannel(c *gin.Context) {
case "single": case "single":
keys = []string{addChannelRequest.Channel.Key} keys = []string{addChannelRequest.Channel.Key}
default: default:
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Unsupported add mode")
"success": false,
"message": "不支持的添加模式",
})
return return
} }
@@ -905,10 +920,7 @@ func DisableTagChannels(c *gin.Context) {
channelTag := ChannelTag{} channelTag := ChannelTag{}
err := c.ShouldBindJSON(&channelTag) err := c.ShouldBindJSON(&channelTag)
if err != nil || channelTag.Tag == "" { if err != nil || channelTag.Tag == "" {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Invalid parameters")
"success": false,
"message": "参数错误",
})
return return
} }
channels, err := model.GetChannelsByTag(channelTag.Tag, false, false) channels, err := model.GetChannelsByTag(channelTag.Tag, false, false)
@@ -938,10 +950,7 @@ func EnableTagChannels(c *gin.Context) {
channelTag := ChannelTag{} channelTag := ChannelTag{}
err := c.ShouldBindJSON(&channelTag) err := c.ShouldBindJSON(&channelTag)
if err != nil || channelTag.Tag == "" { if err != nil || channelTag.Tag == "" {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Invalid parameters")
"success": false,
"message": "参数错误",
})
return return
} }
err = model.EnableChannelByTag(channelTag.Tag) err = model.EnableChannelByTag(channelTag.Tag)
@@ -964,31 +973,22 @@ func EditTagChannels(c *gin.Context) {
channelTag := ChannelTag{} channelTag := ChannelTag{}
err := c.ShouldBindJSON(&channelTag) err := c.ShouldBindJSON(&channelTag)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Invalid parameters")
"success": false,
"message": "参数错误",
})
return return
} }
if channelTag.Tag == "" { if channelTag.Tag == "" {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Tag cannot be empty")
"success": false,
"message": "tag不能为空",
})
return return
} }
if (channelTag.ParamOverride != nil || channelTag.HeaderOverride != nil) && if (channelTag.ParamOverride != nil || channelTag.HeaderOverride != nil) &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) { !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege) common.ApiErrorT(c, "Unauthorized, insufficient privileges")
return return
} }
if channelTag.ParamOverride != nil { if channelTag.ParamOverride != nil {
trimmed := strings.TrimSpace(*channelTag.ParamOverride) trimmed := strings.TrimSpace(*channelTag.ParamOverride)
if trimmed != "" && !json.Valid([]byte(trimmed)) { if trimmed != "" && !json.Valid([]byte(trimmed)) {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Parameter override must be valid JSON format")
"success": false,
"message": "参数覆盖必须是合法的 JSON 格式",
})
return return
} }
channelTag.ParamOverride = common.GetPointer[string](trimmed) channelTag.ParamOverride = common.GetPointer[string](trimmed)
@@ -996,10 +996,7 @@ func EditTagChannels(c *gin.Context) {
if channelTag.HeaderOverride != nil { if channelTag.HeaderOverride != nil {
trimmed := strings.TrimSpace(*channelTag.HeaderOverride) trimmed := strings.TrimSpace(*channelTag.HeaderOverride)
if trimmed != "" && !json.Valid([]byte(trimmed)) { if trimmed != "" && !json.Valid([]byte(trimmed)) {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Header override must be valid JSON format")
"success": false,
"message": "请求头覆盖必须是合法的 JSON 格式",
})
return return
} }
channelTag.HeaderOverride = common.GetPointer[string](trimmed) channelTag.HeaderOverride = common.GetPointer[string](trimmed)
@@ -1029,10 +1026,7 @@ func DeleteChannelBatch(c *gin.Context) {
channelBatch := ChannelBatch{} channelBatch := ChannelBatch{}
err := c.ShouldBindJSON(&channelBatch) err := c.ShouldBindJSON(&channelBatch)
if err != nil || len(channelBatch.Ids) == 0 { if err != nil || len(channelBatch.Ids) == 0 {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Invalid parameters")
"success": false,
"message": "参数错误",
})
return return
} }
deletedCount, err := model.BatchDeleteChannels(channelBatch.Ids) deletedCount, err := model.BatchDeleteChannels(channelBatch.Ids)
@@ -1088,7 +1082,7 @@ func UpdateChannel(c *gin.Context) {
return return
} }
if _, ok := requestData["status"]; ok { if _, ok := requestData["status"]; ok {
common.ApiErrorI18n(c, i18n.MsgInvalidParams) common.ApiErrorT(c, "Invalid parameters")
return return
} }
clearChannelReadOnlyFields(&channel, requestData) clearChannelReadOnlyFields(&channel, requestData)
@@ -1106,35 +1100,42 @@ func UpdateChannel(c *gin.Context) {
(channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "") (channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "")
// 使用统一的校验函数 // 使用统一的校验函数
if err := validateChannel(&channel.Channel, false); err != nil { if err := validateChannel(&channel.Channel, false); err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiError(c, err)
"success": false,
"message": err.Error(),
})
return return
} }
// Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request. // Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request.
originChannel, err := model.GetChannelById(channel.Id, true) originChannel, err := model.GetChannelById(channel.Id, true)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiError(c, err)
"success": false,
"message": err.Error(),
})
return return
} }
originProxy := originChannel.GetSetting().Proxy originProxy := originChannel.GetSetting().Proxy
proxyChanged := false proxyChanged := false
if _, settingProvided := requestData["setting"]; settingProvided { _, settingProvided := requestData["setting"]
if settingProvided {
newProxy, _ := service.NormalizeProxyURL(channel.GetSetting().Proxy) newProxy, _ := service.NormalizeProxyURL(channel.GetSetting().Proxy)
normalizedOriginProxy, originProxyErr := service.NormalizeProxyURL(originProxy) normalizedOriginProxy, originProxyErr := service.NormalizeProxyURL(originProxy)
proxyChanged = originProxyErr != nil || normalizedOriginProxy != newProxy proxyChanged = originProxyErr != nil || normalizedOriginProxy != newProxy
} }
// Changing which plugins a channel binds needs the bind permission on any
// channel type; resubmitting an unchanged New API binding list does not, so
// administrators without it can still edit the rest of a gateway channel.
if settingProvided &&
!slices.Equal(channel.GetSetting().TaskPluginBindings(), originChannel.GetSetting().TaskPluginBindings()) &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "task plugin channels require the task_plugin.bind permission",
})
return
}
// Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained. // Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained.
channel.ChannelInfo = originChannel.ChannelInfo channel.ChannelInfo = originChannel.ChannelInfo
if channelHasSensitiveChanges(&channel, originChannel, requestData) && if channelHasSensitiveChanges(&channel, originChannel, requestData) &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) { !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege) common.ApiErrorT(c, "Unauthorized, insufficient privileges")
return return
} }
@@ -1173,10 +1174,7 @@ func UpdateChannel(c *gin.Context) {
if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") { if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") {
array, err := getVertexArrayKeys(channel.Key) array, err := getVertexArrayKeys(channel.Key)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Failed to parse appended keys: {{error}}", map[string]any{"error": err.Error()})
"success": false,
"message": "追加密钥解析失败: " + err.Error(),
})
return return
} }
newKeys = array newKeys = array
@@ -1271,12 +1269,12 @@ func UpdateChannel(c *gin.Context) {
func UpdateChannelStatus(c *gin.Context) { func UpdateChannelStatus(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id")) id, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams) common.ApiErrorT(c, "Invalid parameters")
return return
} }
req := ChannelStatusRequest{} req := ChannelStatusRequest{}
if err := c.ShouldBindJSON(&req); err != nil || !isManageableChannelStatus(req.Status) { if err := c.ShouldBindJSON(&req); err != nil || !isManageableChannelStatus(req.Status) {
common.ApiErrorI18n(c, i18n.MsgInvalidParams) common.ApiErrorT(c, "Invalid parameters")
return return
} }
changed := model.UpdateChannelStatus(id, "", req.Status, "manual operation") changed := model.UpdateChannelStatus(id, "", req.Status, "manual operation")
@@ -1301,7 +1299,7 @@ func UpdateChannelStatus(c *gin.Context) {
func BatchUpdateChannelStatus(c *gin.Context) { func BatchUpdateChannelStatus(c *gin.Context) {
req := ChannelStatusBatchRequest{} req := ChannelStatusBatchRequest{}
if err := c.ShouldBindJSON(&req); err != nil || len(req.Ids) == 0 || !isManageableChannelStatus(req.Status) { if err := c.ShouldBindJSON(&req); err != nil || len(req.Ids) == 0 || !isManageableChannelStatus(req.Status) {
common.ApiErrorI18n(c, i18n.MsgInvalidParams) common.ApiErrorT(c, "Invalid parameters")
return return
} }
changedCount := 0 changedCount := 0
@@ -1441,10 +1439,7 @@ func FetchModels(c *gin.Context) {
var err error var err error
channel, err = buildAdvancedCustomModelPreviewChannel(req) channel, err = buildAdvancedCustomModelPreviewChannel(req)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiError(c, err)
"success": false,
"message": err.Error(),
})
return return
} }
} else { } else {
@@ -1469,10 +1464,7 @@ func FetchModels(c *gin.Context) {
models, err := fetchChannelUpstreamModelIDs(channel) models, err := fetchChannelUpstreamModelIDs(channel)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Failed to fetch models: {{error}}", map[string]any{"error": err.Error()})
"success": false,
"message": fmt.Sprintf("获取模型列表失败: %s", err.Error()),
})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -1486,10 +1478,7 @@ func BatchSetChannelTag(c *gin.Context) {
channelBatch := ChannelBatch{} channelBatch := ChannelBatch{}
err := c.ShouldBindJSON(&channelBatch) err := c.ShouldBindJSON(&channelBatch)
if err != nil || len(channelBatch.Ids) == 0 { if err != nil || len(channelBatch.Ids) == 0 {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Invalid parameters")
"success": false,
"message": "参数错误",
})
return return
} }
err = model.BatchSetChannelTag(channelBatch.Ids, channelBatch.Tag) err = model.BatchSetChannelTag(channelBatch.Ids, channelBatch.Tag)
@@ -1512,10 +1501,7 @@ func BatchSetChannelTag(c *gin.Context) {
func GetTagModels(c *gin.Context) { func GetTagModels(c *gin.Context) {
tag := c.Query("tag") tag := c.Query("tag")
if tag == "" { if tag == "" {
c.JSON(http.StatusBadRequest, gin.H{ common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Tag cannot be empty"))
"success": false,
"message": "tag不能为空",
})
return return
} }
@@ -1554,7 +1540,7 @@ func GetTagModels(c *gin.Context) {
// POST /api/channel/copy/:id // POST /api/channel/copy/:id
// Optional query params: // Optional query params:
// //
// suffix - string appended to the original name (default "_复制") // suffix - string appended to the original name (default "_copy")
// reset_balance - bool, when true will reset balance & used_quota to 0 (default true) // reset_balance - bool, when true will reset balance & used_quota to 0 (default true)
func CopyChannel(c *gin.Context) { func CopyChannel(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id")) id, err := strconv.Atoi(c.Param("id"))
@@ -1563,7 +1549,7 @@ func CopyChannel(c *gin.Context) {
return return
} }
suffix := c.DefaultQuery("suffix", "_复制") suffix := c.DefaultQuery("suffix", "_copy")
resetBalance := true resetBalance := true
if rbStr := c.DefaultQuery("reset_balance", "true"); rbStr != "" { if rbStr := c.DefaultQuery("reset_balance", "true"); rbStr != "" {
if v, err := strconv.ParseBool(rbStr); err == nil { if v, err := strconv.ParseBool(rbStr); err == nil {
@@ -1574,11 +1560,11 @@ func CopyChannel(c *gin.Context) {
// fetch original channel with key // fetch original channel with key
origin, err := model.GetChannelById(id, true) origin, err := model.GetChannelById(id, true)
if err != nil { if err != nil {
common.SysError("failed to get channel by id: " + err.Error()) common.SysError(common.LogText("failed to get channel by id: %s", err.Error()))
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道信息失败,请稍后重试"}) common.ApiErrorT(c, "Failed to fetch channel. Please try again later")
return return
} }
if origin.Type == constant.ChannelTypeTaskPlugin && if (origin.Type == constant.ChannelTypeTaskPlugin || len(origin.GetSetting().TaskPluginBindings()) > 0) &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) { !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "task plugin channels require the task_plugin.bind permission"}) c.JSON(http.StatusOK, gin.H{"success": false, "message": "task plugin channels require the task_plugin.bind permission"})
return return
@@ -1597,15 +1583,15 @@ func CopyChannel(c *gin.Context) {
} }
if err := clone.ValidateSettings(); err != nil { if err := clone.ValidateSettings(); err != nil {
common.SysError("failed to validate cloned channel: " + err.Error()) common.SysError(common.LogText("failed to validate cloned channel: %s", err.Error()))
c.JSON(http.StatusOK, gin.H{"success": false, "message": "Failed to copy channel: invalid channel settings"}) c.JSON(http.StatusOK, gin.H{"success": false, "message": "Failed to copy channel: invalid channel settings"})
return return
} }
// insert // insert
if err := clone.Insert(); err != nil { if err := clone.Insert(); err != nil {
common.SysError("failed to clone channel: " + err.Error()) common.SysError(common.LogText("failed to clone channel: %s", err.Error()))
c.JSON(http.StatusOK, gin.H{"success": false, "message": "复制渠道失败,请稍后重试"}) common.ApiErrorT(c, "Failed to copy channel. Please try again later")
return return
} }
model.InitChannelCache() model.InitChannelCache()
@@ -1660,23 +1646,17 @@ func ManageMultiKeys(c *gin.Context) {
channel, err := model.GetChannelById(request.ChannelId, true) channel, err := model.GetChannelById(request.ChannelId, true)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Channel does not exist")
"success": false,
"message": "渠道不存在",
})
return return
} }
if !channel.ChannelInfo.IsMultiKey { if !channel.ChannelInfo.IsMultiKey {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "This channel is not in multi-key mode")
"success": false,
"message": "该渠道不是多密钥模式",
})
return return
} }
if multiKeyActionRequiresSensitiveWrite(request.Action) && if multiKeyActionRequiresSensitiveWrite(request.Action) &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) { !authz.Can(c.GetInt("id"), c.GetInt("role"), authz.ChannelSensitiveWrite) {
common.ApiErrorI18n(c, i18n.MsgAuthInsufficientPrivilege) common.ApiErrorT(c, "Unauthorized, insufficient privileges")
return return
} }
@@ -1808,19 +1788,13 @@ func ManageMultiKeys(c *gin.Context) {
case "disable_key": case "disable_key":
if request.KeyIndex == nil { if request.KeyIndex == nil {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "No key index specified to disable")
"success": false,
"message": "未指定要禁用的密钥索引",
})
return return
} }
keyIndex := *request.KeyIndex keyIndex := *request.KeyIndex
if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Key index out of range")
"success": false,
"message": "密钥索引超出范围",
})
return return
} }
@@ -1846,27 +1820,18 @@ func ManageMultiKeys(c *gin.Context) {
if shouldCloseWebSocket { if shouldCloseWebSocket {
closeActiveChannelWebSockets([]int{channel.Id}) closeActiveChannelWebSockets([]int{channel.Id})
} }
c.JSON(http.StatusOK, gin.H{ common.ApiSuccessT(c, "Key disabled", nil)
"success": true,
"message": "密钥已禁用",
})
return return
case "enable_key": case "enable_key":
if request.KeyIndex == nil { if request.KeyIndex == nil {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "No key index specified to enable")
"success": false,
"message": "未指定要启用的密钥索引",
})
return return
} }
keyIndex := *request.KeyIndex keyIndex := *request.KeyIndex
if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Key index out of range")
"success": false,
"message": "密钥索引超出范围",
})
return return
} }
@@ -1889,10 +1854,7 @@ func ManageMultiKeys(c *gin.Context) {
} }
model.InitChannelCache() model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{ common.ApiSuccessT(c, "Key enabled", nil)
"success": true,
"message": "密钥已启用",
})
return return
case "enable_all_keys": case "enable_all_keys":
@@ -1914,10 +1876,7 @@ func ManageMultiKeys(c *gin.Context) {
} }
model.InitChannelCache() model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{ common.ApiSuccessT(c, "Enabled {{count}} keys", nil, map[string]any{"count": enabledCount})
"success": true,
"message": fmt.Sprintf("已启用 %d 个密钥", enabledCount),
})
return return
case "disable_all_keys": case "disable_all_keys":
@@ -1947,10 +1906,7 @@ func ManageMultiKeys(c *gin.Context) {
} }
if disabledCount == 0 { if disabledCount == 0 {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "No keys to disable")
"success": false,
"message": "没有可禁用的密钥",
})
return return
} }
@@ -1964,27 +1920,18 @@ func ManageMultiKeys(c *gin.Context) {
if shouldCloseWebSocket { if shouldCloseWebSocket {
closeActiveChannelWebSockets([]int{channel.Id}) closeActiveChannelWebSockets([]int{channel.Id})
} }
c.JSON(http.StatusOK, gin.H{ common.ApiSuccessT(c, "Disabled {{count}} keys", nil, map[string]any{"count": disabledCount})
"success": true,
"message": fmt.Sprintf("已禁用 %d 个密钥", disabledCount),
})
return return
case "delete_key": case "delete_key":
if request.KeyIndex == nil { if request.KeyIndex == nil {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "No key index specified to delete")
"success": false,
"message": "未指定要删除的密钥索引",
})
return return
} }
keyIndex := *request.KeyIndex keyIndex := *request.KeyIndex
if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Key index out of range")
"success": false,
"message": "密钥索引超出范围",
})
return return
} }
@@ -2023,10 +1970,7 @@ func ManageMultiKeys(c *gin.Context) {
} }
if len(remainingKeys) == 0 { if len(remainingKeys) == 0 {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Cannot delete the last key")
"success": false,
"message": "不能删除最后一个密钥",
})
return return
} }
@@ -2047,10 +1991,7 @@ func ManageMultiKeys(c *gin.Context) {
if shouldCloseWebSocket { if shouldCloseWebSocket {
closeActiveChannelWebSockets([]int{channel.Id}) closeActiveChannelWebSockets([]int{channel.Id})
} }
c.JSON(http.StatusOK, gin.H{ common.ApiSuccessT(c, "Key deleted", nil)
"success": true,
"message": "密钥已删除",
})
return return
case "delete_disabled_keys": case "delete_disabled_keys":
@@ -2094,10 +2035,7 @@ func ManageMultiKeys(c *gin.Context) {
} }
if deletedCount == 0 { if deletedCount == 0 {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "No auto-disabled keys to delete")
"success": false,
"message": "没有需要删除的自动禁用密钥",
})
return return
} }
@@ -2118,18 +2056,11 @@ func ManageMultiKeys(c *gin.Context) {
if shouldCloseWebSocket { if shouldCloseWebSocket {
closeActiveChannelWebSockets([]int{channel.Id}) closeActiveChannelWebSockets([]int{channel.Id})
} }
c.JSON(http.StatusOK, gin.H{ common.ApiSuccessT(c, "Deleted {{count}} auto-disabled keys", deletedCount, map[string]any{"count": deletedCount})
"success": true,
"message": fmt.Sprintf("已删除 %d 个自动禁用的密钥", deletedCount),
"data": deletedCount,
})
return return
default: default:
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Unsupported operation")
"success": false,
"message": "不支持的操作",
})
return return
} }
} }
@@ -2382,10 +2313,7 @@ func OllamaVersion(c *gin.Context) {
key := strings.Split(channel.Key, "\n")[0] key := strings.Split(channel.Key, "\n")[0]
version, err := ollama.FetchOllamaVersion(baseURL, key) version, err := ollama.FetchOllamaVersion(baseURL, key)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ common.ApiErrorT(c, "Failed to fetch Ollama version: {{error}}", map[string]any{"error": err.Error()})
"success": false,
"message": fmt.Sprintf("获取Ollama版本失败: %s", err.Error()),
})
return return
} }
+3 -8
View File
@@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -34,19 +35,13 @@ func ClearChannelAffinityCache(c *gin.Context) {
} }
if ruleName == "" { if ruleName == "" {
c.JSON(http.StatusBadRequest, gin.H{ common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Missing parameter rule_name, or use all=true to clear everything"))
"success": false,
"message": "缺少参数:rule_name,或使用 all=true 清空全部",
})
return return
} }
deleted, err := service.ClearChannelAffinityCacheByRuleName(ruleName) deleted, err := service.ClearChannelAffinityCacheByRuleName(ruleName)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{ common.ApiErrorStatus(c, http.StatusBadRequest, err)
"success": false,
"message": err.Error(),
})
return return
} }
+159
View File
@@ -0,0 +1,159 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// setupSub2APIBalanceTest wires an in-memory SQLite DB into model.DB so
// Channel.UpdateBalance can persist, mirroring the pattern used by other
// controller tests.
func setupSub2APIBalanceTest(t *testing.T) {
t.Helper()
originalDB := model.DB
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Discard,
})
require.NoError(t, err)
sqlDB, err := database.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
require.NoError(t, database.AutoMigrate(&model.Channel{}))
model.DB = database
t.Cleanup(func() {
model.DB = originalDB
})
}
func newSub2APITestChannel(t *testing.T, serverURL string) *model.Channel {
t.Helper()
channel := &model.Channel{
Type: constant.ChannelTypeSub2API,
BaseURL: &serverURL,
Key: "sk-test",
Name: "sub2api-test",
}
require.NoError(t, model.DB.Create(channel).Error)
return channel
}
func TestUpdateChannelSub2APIBalanceRemainingPreferred(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v1/usage", r.URL.Path)
assert.Equal(t, "Bearer sk-test", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"balance": 100.0, "remaining": 986.17194888, "isValid": true, "unit": "USD"}`))
}))
defer server.Close()
channel := newSub2APITestChannel(t, server.URL)
balance, err := updateChannelSub2APIBalance(channel)
require.NoError(t, err)
assert.InDelta(t, 986.17194888, balance, 1e-9)
assert.InDelta(t, 986.17194888, channel.Balance, 1e-9)
}
func TestUpdateChannelSub2APIBalanceWalletFallback(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"balance": 42.5, "isValid": true, "unit": "USD"}`))
}))
defer server.Close()
channel := newSub2APITestChannel(t, server.URL)
balance, err := updateChannelSub2APIBalance(channel)
require.NoError(t, err)
assert.InDelta(t, 42.5, balance, 1e-9)
}
func TestUpdateChannelSub2APIBalanceSubscriptionKeyMissingFields(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"balance": null, "remaining": null, "isValid": true, "mode": "unrestricted"}`))
}))
defer server.Close()
channel := newSub2APITestChannel(t, server.URL)
_, err := updateChannelSub2APIBalance(channel)
require.Error(t, err)
}
func TestUpdateChannelSub2APIBalanceInvalidKey(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"isValid": false}`))
}))
defer server.Close()
channel := newSub2APITestChannel(t, server.URL)
_, err := updateChannelSub2APIBalance(channel)
require.EqualError(t, err, "Sub2API key is invalid (isValid=false)")
}
func TestUpdateChannelSub2APIBalanceUnlimitedSubscription(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"remaining": -1, "isValid": true, "mode": "unrestricted", "subscription": {"daily_limit_usd": null, "weekly_limit_usd": null, "monthly_limit_usd": null}}`))
}))
defer server.Close()
channel := newSub2APITestChannel(t, server.URL)
require.NoError(t, model.DB.Model(channel).Update("balance", 12.5).Error)
_, err := updateChannelSub2APIBalance(channel)
require.EqualError(t, err, "Sub2API /v1/usage reports an unlimited subscription without a finite balance")
stored := model.Channel{}
require.NoError(t, model.DB.First(&stored, channel.Id).Error)
assert.InDelta(t, 12.5, stored.Balance, 1e-9)
}
func TestUpdateChannelSub2APIBalanceNegativeWalletBalance(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"balance": -1, "remaining": -1, "isValid": true, "mode": "unrestricted", "unit": "USD"}`))
}))
defer server.Close()
channel := newSub2APITestChannel(t, server.URL)
balance, err := updateChannelSub2APIBalance(channel)
require.NoError(t, err)
assert.InDelta(t, -1, balance, 1e-9)
}
func TestUpdateChannelSub2APIBalanceMissingBaseURL(t *testing.T) {
setupSub2APIBalanceTest(t)
empty := ""
channel := &model.Channel{BaseURL: &empty, Key: "sk-test"}
_, err := updateChannelSub2APIBalance(channel)
require.Error(t, err)
}
func TestUpdateStandardChannelBalanceRoutesSub2API(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"balance": 77.7, "isValid": true, "unit": "USD"}`))
}))
defer server.Close()
channel := newSub2APITestChannel(t, server.URL)
result, err := updateChannelBalance(channel)
require.NoError(t, err)
assert.InDelta(t, 77.7, result.Balance, 1e-9)
}
+49
View File
@@ -2,9 +2,13 @@ package controller
import ( import (
"math" "math"
"net/http"
"net/http/httptest"
"testing" "testing"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -114,3 +118,48 @@ func TestGetDeepSeekBalanceUSD(t *testing.T) {
}) })
} }
} }
func TestUpdateChannelNewAPIBalanceUsesTheSmallerRemainingQuota(t *testing.T) {
for _, tc := range []struct {
name string
usage string
want float64
wantErr string
}{
{name: "user quota below the key quota", usage: `{"code":true,"data":{"total_available":2000000,"unlimited_quota":false,"user_quota":{"total_available":500000}}}`, want: 0.5},
{name: "key quota below the user quota", usage: `{"code":true,"data":{"total_available":500000,"unlimited_quota":false,"user_quota":{"total_available":2000000}}}`, want: 0.5},
{name: "unlimited key uses the user quota", usage: `{"code":true,"data":{"total_available":0,"unlimited_quota":true,"user_quota":{"total_available":3000000}}}`, want: 3},
{name: "key quota alone without user quota", usage: `{"code":true,"data":{"total_available":1500000,"unlimited_quota":false}}`, want: 1.5},
{name: "unlimited key without user quota", usage: `{"code":true,"data":{"total_available":0,"unlimited_quota":true}}`, wantErr: "Allow querying user quota"},
{name: "upstream rejects the key", usage: `{"success":false,"message":"invalid token"}`, wantErr: "invalid token"},
} {
t.Run(tc.name, func(t *testing.T) {
setupSub2APIBalanceTest(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/usage/token/":
assert.Equal(t, "Bearer sk-upstream", r.Header.Get("Authorization"))
_, _ = w.Write([]byte(tc.usage))
case "/api/status":
assert.Empty(t, r.Header.Get("Authorization"), "the key is not sent to the public status endpoint")
_, _ = w.Write([]byte(`{"success":true,"data":{"quota_per_unit":1000000}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
baseURL := server.URL + "/"
channel := &model.Channel{Type: constant.ChannelTypeNewAPI, BaseURL: &baseURL, Key: "sk-upstream", Name: "new-api-upstream"}
require.NoError(t, model.DB.Create(channel).Error)
result, err := updateChannelBalance(channel)
if tc.wantErr != "" {
require.ErrorContains(t, err, tc.wantErr)
return
}
require.NoError(t, err)
assert.InDelta(t, tc.want, result.Balance, 1e-9)
assert.InDelta(t, tc.want, channel.Balance, 1e-9)
})
}
}
+40
View File
@@ -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})
}
@@ -164,3 +164,69 @@ export function parseTaskResult() { return {}; }
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, string(encoded), `"base_url_source":"plugin_default"`) assert.Contains(t, string(encoded), `"base_url_source":"plugin_default"`)
} }
func putUpdateChannel(t *testing.T, userID, role int, body string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Set("id", userID)
context.Set("role", role)
context.Request = httptest.NewRequest(http.MethodPut, "/api/channel", strings.NewReader(body))
context.Request.Header.Set("Content-Type", "application/json")
UpdateChannel(context)
return recorder
}
func TestNewAPIChannelPluginBindingsRequireBindPermission(t *testing.T) {
setupTaskPluginBindChannelTest(t)
const key = "gateway-bind"
source := `
export const meta = {apiVersion: 1, key: "gateway-bind", name: "Gateway", version: "1.0.0", author: {name: "Test"}, models: ["gateway-doc"], fetchMode: "per_task", upstreams: ["vendor", "new_api"]};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
bound := `{"mode":"single","channel":{"type":60,"name":"gateway","key":"sk","models":"gateway-doc","group":"default","base_url":"https://gateway.example","setting":"{\"task_extend_plugin_keys\":[\"gateway-bind\"]}"}}`
unbound := `{"mode":"single","channel":{"type":60,"name":"plain-gateway","key":"sk","models":"gpt","group":"default","base_url":"https://gateway.example"}}`
adminDenied := postAddChannel(t, 2, common.RoleAdminUser, bound)
assert.Contains(t, adminDenied.Body.String(), "task plugin channels require the task_plugin.bind permission")
rootAllowed := postAddChannel(t, 1, common.RoleRootUser, bound)
assert.Contains(t, rootAllowed.Body.String(), `"success":true`)
adminUnbound := postAddChannel(t, 2, common.RoleAdminUser, unbound)
assert.Contains(t, adminUnbound.Body.String(), `"success":true`, "a gateway channel without plugin bindings needs no bind permission")
baseURL := "https://gateway.example"
setting := `{"task_extend_plugin_keys":["gateway-bind"]}`
channel := model.Channel{Type: constant.ChannelTypeNewAPI, Status: common.ChannelStatusEnabled, Name: "existing-gateway", Models: "gateway-doc", Group: "default", Key: "sk", BaseURL: &baseURL, Setting: &setting}
require.NoError(t, channel.Insert())
update := func(name, setting string) string {
return fmt.Sprintf(`{"id":%d,"type":60,"name":%q,"key":"sk","models":"gateway-doc","group":"default","base_url":"https://gateway.example","setting":%q}`, channel.Id, name, setting)
}
unchanged := putUpdateChannel(t, 2, common.RoleAdminUser, update("renamed-gateway", setting))
assert.Contains(t, unchanged.Body.String(), `"success":true`, "resubmitting the stored bindings does not need the bind permission")
assert.NotContains(t, unchanged.Body.String(), "task_plugin.bind")
rebound := putUpdateChannel(t, 2, common.RoleAdminUser, update("renamed-gateway", `{"task_extend_plugin_keys":[]}`))
assert.Contains(t, rebound.Body.String(), "task plugin channels require the task_plugin.bind permission")
rootRebound := putUpdateChannel(t, 1, common.RoleRootUser, update("renamed-gateway", `{"task_extend_plugin_keys":[]}`))
assert.Contains(t, rootRebound.Body.String(), `"success":true`)
require.NoError(t, model.DB.Model(&model.Channel{}).Where("id = ?", channel.Id).Update("setting", setting).Error)
copyChannel := func(userID, role int) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Set("id", userID)
context.Set("role", role)
context.Params = gin.Params{{Key: "id", Value: fmt.Sprint(channel.Id)}}
context.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/channel/copy/%d", channel.Id), nil)
CopyChannel(context)
return recorder
}
assert.Contains(t, copyChannel(2, common.RoleAdminUser).Body.String(), "task plugin channels require the task_plugin.bind permission")
assert.Contains(t, copyChannel(1, common.RoleRootUser).Body.String(), `"success":true`)
}
@@ -67,3 +67,49 @@ export function parseTaskResult() { return {}; }
require.NoError(t, validateChannel(channel, false)) require.NoError(t, validateChannel(channel, false))
assert.Equal(t, explicit, *channel.BaseURL, "an administrator value is never replaced by the plugin default") assert.Equal(t, explicit, *channel.BaseURL, "an administrator value is never replaced by the plugin default")
} }
func TestValidateNewAPIChannelTaskPluginExtensions(t *testing.T) {
// gateway-ext-vendor never declares new_api, so it can only run against
// its vendor and must be refused on a New API channel.
for key, upstreams := range map[string]string{"gateway-ext-a": `upstreams: ["vendor", "new_api"],`, "gateway-ext-b": `upstreams: ["new_api"],`, "gateway-ext-vendor": ""} {
source := `
export const meta = {apiVersion: 1, key: "` + key + `", name: "Gateway", version: "1.0.0", author: {name: "Test"}, models: ["doc-` + key + `"], fetchMode: "per_task", ` + upstreams + `};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
}
baseURL := "https://gateway.example"
cases := []struct {
name, setting, wantErr string
}{
{"single and extension keys", `{"task_plugin_key":"gateway-ext-a","task_extend_plugin_keys":["gateway-ext-b"]}`, ""},
{"extension keys only", `{"task_extend_plugin_keys":["gateway-ext-a","gateway-ext-b"]}`, ""},
{"unregistered", `{"task_extend_plugin_keys":["gateway-ext-a","missing"]}`, `task plugin "missing" is not registered`},
{"vendor-only plugin as extension", `{"task_extend_plugin_keys":["gateway-ext-a","gateway-ext-vendor"]}`, `task plugin "gateway-ext-vendor" does not support a New API upstream`},
{"vendor-only plugin as single key", `{"task_plugin_key":"gateway-ext-vendor"}`, `task plugin "gateway-ext-vendor" does not support a New API upstream`},
{"duplicate", `{"task_extend_plugin_keys":["gateway-ext-a","gateway-ext-a"]}`, "bound more than once"},
{"duplicate of single key", `{"task_plugin_key":"gateway-ext-a","task_extend_plugin_keys":["gateway-ext-a"]}`, "bound more than once"},
{"padded", `{"task_extend_plugin_keys":[" gateway-ext-a"]}`, "is invalid"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
setting := tc.setting
channel := &model.Channel{Type: constant.ChannelTypeNewAPI, BaseURL: &baseURL, Setting: &setting}
err := validateChannel(channel, false)
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tc.wantErr)
})
}
setting := `{"task_extend_plugin_keys":["gateway-ext-a"]}`
channel := &model.Channel{Type: constant.ChannelTypeOpenAI, Setting: &setting}
require.ErrorContains(t, validateChannel(channel, false), "only supported on New API channels")
}
+1 -1
View File
@@ -491,5 +491,5 @@ func TestTestAllChannelsRejectsExistingActiveTask(t *testing.T) {
require.Equal(t, http.StatusConflict, recorder.Code) require.Equal(t, http.StatusConflict, recorder.Code)
require.Contains(t, recorder.Body.String(), existing.TaskID) require.Contains(t, recorder.Body.String(), existing.TaskID)
require.Contains(t, recorder.Body.String(), "已有通道测试任务正在运行或等待中") require.Contains(t, recorder.Body.String(), "A channel test task is already running or queued")
} }
+4
View File
@@ -82,6 +82,8 @@ func TestChannelTestOpenAIChatCompatibility(t *testing.T) {
}{ }{
{name: "GPT6 automatic", model: "gpt-6-astra", upstream: "gpt-6-astra", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"}, {name: "GPT6 automatic", model: "gpt-6-astra", upstream: "gpt-6-astra", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
{name: "GPT6 explicit Azure stream", model: "gpt-6-astra", upstream: "gpt-6-astra", endpoint: string(constant.EndpointTypeOpenAI), channelType: constant.ChannelTypeAzure, stream: true, wantLimit: "max_completion_tokens"}, {name: "GPT6 explicit Azure stream", model: "gpt-6-astra", upstream: "gpt-6-astra", endpoint: string(constant.EndpointTypeOpenAI), channelType: constant.ChannelTypeAzure, stream: true, wantLimit: "max_completion_tokens"},
{name: "GPT6 sol", model: "gpt-6-sol", upstream: "gpt-6-sol", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
{name: "GPT6 luna", model: "gpt-6-luna", upstream: "gpt-6-luna", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
{name: "alias maps to GPT6", model: "customer-model", upstream: "gpt-6-astra", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"}, {name: "alias maps to GPT6", model: "customer-model", upstream: "gpt-6-astra", channelType: constant.ChannelTypeOpenAI, wantLimit: "max_completion_tokens"},
{name: "GPT5 alias maps to Qwen", model: "gpt-5.6-luna", upstream: "qwen-turbo", channelType: constant.ChannelTypeAli, wantLimit: "max_tokens"}, {name: "GPT5 alias maps to Qwen", model: "gpt-5.6-luna", upstream: "qwen-turbo", channelType: constant.ChannelTypeAli, wantLimit: "max_tokens"},
{name: "GPT5 stream", model: "gpt-5.6-luna", upstream: "gpt-5.6-luna", channelType: constant.ChannelTypeOpenAI, stream: true, wantLimit: "max_completion_tokens"}, {name: "GPT5 stream", model: "gpt-5.6-luna", upstream: "gpt-5.6-luna", channelType: constant.ChannelTypeOpenAI, stream: true, wantLimit: "max_completion_tokens"},
@@ -136,6 +138,8 @@ func TestOpenAIChatSamplingCompatibility(t *testing.T) {
{name: "GPT6", model: "gpt-6-astra", wantRole: "developer", wantParams: `{}`}, {name: "GPT6", model: "gpt-6-astra", wantRole: "developer", wantParams: `{}`},
{name: "GPT6 snapshot", model: "gpt-6-astra-2026-09-03", wantRole: "developer", wantParams: `{}`}, {name: "GPT6 snapshot", model: "gpt-6-astra-2026-09-03", wantRole: "developer", wantParams: `{}`},
{name: "GPT6 effort suffix", model: "gpt-6-astra-high", wantModel: "gpt-6-astra", wantEffort: "high", wantRole: "developer", wantParams: `{}`}, {name: "GPT6 effort suffix", model: "gpt-6-astra-high", wantModel: "gpt-6-astra", wantEffort: "high", wantRole: "developer", wantParams: `{}`},
{name: "GPT6 sol none keeps sampling", model: "gpt-6-sol", wantRole: "developer", wantParams: sampling},
{name: "GPT6 luna effort drops sampling", model: "gpt-6-luna-high", wantModel: "gpt-6-luna", wantEffort: "high", wantRole: "developer", wantParams: `{}`},
{name: "none effort suffix", model: "gpt-5.2-none", wantModel: "gpt-5.2", wantEffort: "none", wantRole: "developer", wantParams: sampling}, {name: "none effort suffix", model: "gpt-5.2-none", wantModel: "gpt-5.2", wantEffort: "none", wantRole: "developer", wantParams: sampling},
{name: "modifier overrides explicit effort", model: "gpt-5.2@thinking:off", effort: "high", wantModel: "gpt-5.2", wantEffort: "none", wantRole: "developer", wantParams: sampling}, {name: "modifier overrides explicit effort", model: "gpt-5.2@thinking:off", effort: "high", wantModel: "gpt-5.2", wantEffort: "none", wantRole: "developer", wantParams: sampling},
{name: "mapped modifier wins", model: "customer-model@thinking:off", mapping: map[string]string{"customer-model": "gpt-5.2@effort:high"}, wantModel: "gpt-5.2", wantEffort: "high", wantRole: "developer", wantParams: `{}`}, {name: "mapped modifier wins", model: "customer-model@thinking:off", mapping: map[string]string{"customer-model": "gpt-5.2@effort:high"}, wantModel: "gpt-5.2", wantEffort: "high", wantRole: "developer", wantParams: `{}`},
+78 -63
View File
@@ -15,6 +15,7 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin" "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay/channel/advancedcustom" "github.com/QuantumNous/new-api/relay/channel/advancedcustom"
@@ -346,7 +347,8 @@ func getFetchModelsResponseBody(method string, requestURL string, channel *model
request.Host = headers.Get(name) request.Host = headers.Get(name)
} }
} }
client, err := service.NewProxyHttpClient(channel.GetSetting().Proxy) setting := channel.GetSetting()
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -388,10 +390,10 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
if channel.Type == constant.ChannelTypeGemini { if channel.Type == constant.ChannelTypeGemini {
key, _, apiErr := channel.GetNextEnabledKey() key, _, apiErr := channel.GetNextEnabledKey()
if apiErr != nil { if apiErr != nil {
return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr) return nil, fmt.Errorf("failed to get channel key: %w", apiErr)
} }
key = strings.TrimSpace(key) key = strings.TrimSpace(key)
models, err := gemini.FetchGeminiModels(baseURL, key, channel.GetSetting().Proxy) models, err := gemini.FetchGeminiModels(baseURL, key, channel.GetSetting())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -437,7 +439,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
key, _, apiErr := channel.GetNextEnabledKey() key, _, apiErr := channel.GetNextEnabledKey()
if apiErr != nil { if apiErr != nil {
return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr) return nil, fmt.Errorf("failed to get channel key: %w", apiErr)
} }
key = strings.TrimSpace(key) key = strings.TrimSpace(key)
@@ -467,7 +469,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
func fetchAdvancedCustomUpstreamModelIDs(channel *model.Channel, baseURL string) ([]string, error) { func fetchAdvancedCustomUpstreamModelIDs(channel *model.Channel, baseURL string) ([]string, error) {
key, _, apiErr := channel.GetNextEnabledKey() key, _, apiErr := channel.GetNextEnabledKey()
if apiErr != nil { if apiErr != nil {
return nil, fmt.Errorf("获取渠道密钥失败: %w", apiErr) return nil, fmt.Errorf("failed to get channel key: %w", apiErr)
} }
key = strings.TrimSpace(key) key = strings.TrimSpace(key)
@@ -564,7 +566,7 @@ func refreshChannelRuntimeCache() {
func() { func() {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
common.SysLog(fmt.Sprintf("InitChannelCache panic: %v", r)) common.SysLog(common.LogText("InitChannelCache panic: %v", r))
} }
}() }()
model.InitChannelCache() model.InitChannelCache()
@@ -594,6 +596,7 @@ func shouldSendUpstreamModelUpdateNotification(now int64, changedChannels int, f
} }
func buildUpstreamModelUpdateTaskNotificationContent( func buildUpstreamModelUpdateTaskNotificationContent(
lang string,
checkedChannels int, checkedChannels int,
changedChannels int, changedChannels int,
detectedAddModels int, detectedAddModels int,
@@ -606,50 +609,62 @@ func buildUpstreamModelUpdateTaskNotificationContent(
) string { ) string {
var builder strings.Builder var builder strings.Builder
failedChannels := len(failedChannelIDs) failedChannels := len(failedChannelIDs)
builder.WriteString(fmt.Sprintf( builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateSummary, map[string]any{
"上游模型巡检摘要:检测渠道 %d 个,发现变更 %d 个,新增 %d 个,删除 %d 个,自动同步新增 %d 个,失败 %d 个。", "Checked": checkedChannels,
checkedChannels, "Changed": changedChannels,
changedChannels, "Added": detectedAddModels,
detectedAddModels, "Removed": detectedRemoveModels,
detectedRemoveModels, "AutoAdded": autoAddedModels,
autoAddedModels, "Failed": failedChannels,
failedChannels, }))
))
if len(channelSummaries) > 0 { if len(channelSummaries) > 0 {
displayCount := min(len(channelSummaries), channelUpstreamModelUpdateNotifyMaxChannelDetails) displayCount := min(len(channelSummaries), channelUpstreamModelUpdateNotifyMaxChannelDetails)
builder.WriteString(fmt.Sprintf("\n\n变更渠道明细(展示 %d/%d):", displayCount, len(channelSummaries))) builder.WriteString("\n\n")
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateChangedChannels, map[string]any{
"Shown": displayCount,
"Total": len(channelSummaries),
}))
for _, summary := range channelSummaries[:displayCount] { for _, summary := range channelSummaries[:displayCount] {
builder.WriteString(fmt.Sprintf("\n- %s (+%d / -%d)", summary.ChannelName, summary.AddCount, summary.RemoveCount)) builder.WriteString(fmt.Sprintf("\n- %s (+%d / -%d)", summary.ChannelName, summary.AddCount, summary.RemoveCount))
} }
if len(channelSummaries) > displayCount { if len(channelSummaries) > displayCount {
builder.WriteString(fmt.Sprintf("\n- 其余 %d 个渠道已省略", len(channelSummaries)-displayCount)) builder.WriteString("\n- ")
builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreChannels, map[string]any{
"Count": len(channelSummaries) - displayCount,
}))
} }
} }
normalizedAddModelSamples := normalizeModelNames(addModelSamples) normalizedAddModelSamples := normalizeModelNames(addModelSamples)
if len(normalizedAddModelSamples) > 0 { if len(normalizedAddModelSamples) > 0 {
displayCount := min(len(normalizedAddModelSamples), channelUpstreamModelUpdateNotifyMaxModelDetails) displayCount := min(len(normalizedAddModelSamples), channelUpstreamModelUpdateNotifyMaxModelDetails)
builder.WriteString(fmt.Sprintf("\n\n新增模型示例(展示 %d/%d):%s", builder.WriteString("\n\n")
displayCount, builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateAddedModels, map[string]any{
len(normalizedAddModelSamples), "Shown": displayCount,
strings.Join(normalizedAddModelSamples[:displayCount], ", "), "Total": len(normalizedAddModelSamples),
)) "Models": strings.Join(normalizedAddModelSamples[:displayCount], ", "),
}))
if len(normalizedAddModelSamples) > displayCount { if len(normalizedAddModelSamples) > displayCount {
builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", len(normalizedAddModelSamples)-displayCount)) builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{
"Count": len(normalizedAddModelSamples) - displayCount,
}))
} }
} }
normalizedRemoveModelSamples := normalizeModelNames(removeModelSamples) normalizedRemoveModelSamples := normalizeModelNames(removeModelSamples)
if len(normalizedRemoveModelSamples) > 0 { if len(normalizedRemoveModelSamples) > 0 {
displayCount := min(len(normalizedRemoveModelSamples), channelUpstreamModelUpdateNotifyMaxModelDetails) displayCount := min(len(normalizedRemoveModelSamples), channelUpstreamModelUpdateNotifyMaxModelDetails)
builder.WriteString(fmt.Sprintf("\n\n删除模型示例(展示 %d/%d):%s", builder.WriteString("\n\n")
displayCount, builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateRemovedModels, map[string]any{
len(normalizedRemoveModelSamples), "Shown": displayCount,
strings.Join(normalizedRemoveModelSamples[:displayCount], ", "), "Total": len(normalizedRemoveModelSamples),
)) "Models": strings.Join(normalizedRemoveModelSamples[:displayCount], ", "),
}))
if len(normalizedRemoveModelSamples) > displayCount { if len(normalizedRemoveModelSamples) > displayCount {
builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", len(normalizedRemoveModelSamples)-displayCount)) builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{
"Count": len(normalizedRemoveModelSamples) - displayCount,
}))
} }
} }
@@ -658,14 +673,16 @@ func buildUpstreamModelUpdateTaskNotificationContent(
displayIDs := lo.Map(failedChannelIDs[:displayCount], func(channelID int, _ int) string { displayIDs := lo.Map(failedChannelIDs[:displayCount], func(channelID int, _ int) string {
return fmt.Sprintf("%d", channelID) return fmt.Sprintf("%d", channelID)
}) })
builder.WriteString(fmt.Sprintf( builder.WriteString("\n\n")
"\n\n失败渠道 ID(展示 %d/%d):%s", builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateFailedChannels, map[string]any{
displayCount, "Shown": displayCount,
failedChannels, "Total": failedChannels,
strings.Join(displayIDs, ", "), "Ids": strings.Join(displayIDs, ", "),
)) }))
if failedChannels > displayCount { if failedChannels > displayCount {
builder.WriteString(fmt.Sprintf("(其余 %d 个已省略)", failedChannels-displayCount)) builder.WriteString(i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{
"Count": failedChannels - displayCount,
}))
} }
} }
return builder.String() return builder.String()
@@ -726,7 +743,7 @@ scanLoop:
} }
err := query.Find(&channels).Error err := query.Find(&channels).Error
if err != nil { if err != nil {
common.SysLog(fmt.Sprintf("upstream model update task query failed: %v", err)) common.SysLog(common.LogText("upstream model update task query failed: %v", err))
break break
} }
if len(channels) == 0 { if len(channels) == 0 {
@@ -757,7 +774,7 @@ scanLoop:
if err != nil { if err != nil {
failedChannels++ failedChannels++
failedChannelIDs = append(failedChannelIDs, channel.Id) failedChannelIDs = append(failedChannelIDs, channel.Id)
common.SysLog(fmt.Sprintf("upstream model update check failed: channel_id=%d channel_name=%s err=%v", channel.Id, channel.Name, err)) common.SysLog(common.LogText("upstream model update check failed: channel_id=%d channel_name=%s err=%v", channel.Id, channel.Name, err))
continue continue
} }
currentAddModels := normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels) currentAddModels := normalizeModelNames(settings.UpstreamModelUpdateLastDetectedModels)
@@ -817,7 +834,7 @@ scanLoop:
} }
if checkedChannels > 0 || common.DebugEnabled { if checkedChannels > 0 || common.DebugEnabled {
common.SysLog(fmt.Sprintf( common.SysLog(common.LogText(
"upstream model update task done: checked_channels=%d changed_channels=%d detected_add_models=%d detected_remove_models=%d failed_channels=%d auto_added_models=%d", "upstream model update task done: checked_channels=%d changed_channels=%d detected_add_models=%d detected_remove_models=%d failed_channels=%d auto_added_models=%d",
checkedChannels, checkedChannels,
changedChannels, changedChannels,
@@ -830,27 +847,28 @@ scanLoop:
if changedChannels > 0 || failedChannels > 0 { if changedChannels > 0 || failedChannels > 0 {
now := common.GetTimestamp() now := common.GetTimestamp()
if !shouldSendUpstreamModelUpdateNotification(now, changedChannels, failedChannels) { if !shouldSendUpstreamModelUpdateNotification(now, changedChannels, failedChannels) {
common.SysLog(fmt.Sprintf( common.SysLog(common.LogText(
"upstream model update notification skipped in 24h window: changed_channels=%d failed_channels=%d", "upstream model update notification skipped in 24h window: changed_channels=%d failed_channels=%d",
changedChannels, changedChannels,
failedChannels, failedChannels,
)) ))
return summary return summary
} }
service.NotifyUpstreamModelUpdateWatchers( service.NotifyUpstreamModelUpdateWatchers(func(lang string) (string, string) {
"上游模型巡检通知", return i18n.Translate(lang, i18n.MsgChannelUpstreamUpdateNotifySubject),
buildUpstreamModelUpdateTaskNotificationContent( buildUpstreamModelUpdateTaskNotificationContent(
checkedChannels, lang,
changedChannels, checkedChannels,
detectedAddModels, changedChannels,
detectedRemoveModels, detectedAddModels,
autoAddedModels, detectedRemoveModels,
failedChannelIDs, autoAddedModels,
channelSummaries, failedChannelIDs,
addModelSamples, channelSummaries,
removeModelSamples, addModelSamples,
), removeModelSamples,
) )
})
} }
return summary return summary
} }
@@ -1119,15 +1137,12 @@ func DetectAllChannelUpstreamModelUpdates(c *gin.Context) {
return return
} }
if !created { if !created {
c.JSON(http.StatusConflict, gin.H{ msg := common.NewMessage("A model update task is already running or queued. Cannot start this manual task")
"success": false, common.ApiErrorStatus(c, http.StatusConflict, msg, gin.H{"data": gin.H{
"message": "已有模型更新任务正在运行或等待中,不能启动本次手动任务", "task_id": task.TaskID,
"data": gin.H{ "status": task.Status,
"task_id": task.TaskID, "type": task.Type,
"status": task.Status, }})
"type": task.Type,
},
})
return return
} }
+12 -5
View File
@@ -10,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/dto" "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -542,7 +543,9 @@ func TestBuildUpstreamModelUpdateTaskNotificationContent_OmitOverflowDetails(t *
}) })
} }
require.NoError(t, i18n.Init())
content := buildUpstreamModelUpdateTaskNotificationContent( content := buildUpstreamModelUpdateTaskNotificationContent(
i18n.LangEn,
24, 24,
12, 12,
56, 56,
@@ -561,10 +564,14 @@ func TestBuildUpstreamModelUpdateTaskNotificationContent_OmitOverflowDetails(t *
}, },
) )
require.Contains(t, content, "其余 4 个渠道已省略") require.Contains(t, content, i18n.Translate(i18n.LangEn, i18n.MsgChannelUpstreamUpdateMoreChannels, map[string]any{"Count": 4}))
require.Contains(t, content, "其余 1 个已省略") require.Contains(t, content, i18n.Translate(i18n.LangEn, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{"Count": 1}))
require.Contains(t, content, "失败渠道 ID(展示 10/12)") require.Contains(t, content, i18n.Translate(i18n.LangEn, i18n.MsgChannelUpstreamUpdateFailedChannels, map[string]any{
require.Contains(t, content, "其余 2 个已省略") "Shown": 10,
"Total": 12,
"Ids": "1, 2, 3, 4, 5, 6, 7, 8, 9, 10",
}))
require.Contains(t, content, i18n.Translate(i18n.LangEn, i18n.MsgChannelUpstreamUpdateMoreOmitted, map[string]any{"Count": 2}))
} }
func TestShouldSendUpstreamModelUpdateNotification(t *testing.T) { func TestShouldSendUpstreamModelUpdateNotification(t *testing.T) {
@@ -602,5 +609,5 @@ func TestDetectAllChannelUpstreamModelUpdatesRejectsExistingActiveTask(t *testin
require.Equal(t, http.StatusConflict, recorder.Code) require.Equal(t, http.StatusConflict, recorder.Code)
require.Contains(t, recorder.Body.String(), existing.TaskID) require.Contains(t, recorder.Body.String(), existing.TaskID)
require.Contains(t, recorder.Body.String(), "已有模型更新任务正在运行或等待中") require.Contains(t, recorder.Body.String(), "A model update task is already running or queued")
} }
Loaded 100 of 1022 files, more files were not shown because too many files have changed in this diff. Show more