diff --git a/.agents/rules/billing.md b/.agents/rules/billing.md index 5fac8de..843948c 100644 --- a/.agents/rules/billing.md +++ b/.agents/rules/billing.md @@ -9,7 +9,7 @@ Mandatory conventions for quota, pricing, pre-consume, settlement, refund, and u **Billing safety invariants:** Quota/billing code MUST never produce a negative charge (a credit) from arithmetic overflow or unvalidated input. Apply defense in depth: - Every user-controlled quantity that becomes a billing multiplier (image `n`, video `seconds`/`duration`, resolution/quality ratios, batch counts) MUST be bounded before it reaches quota calculation. Reject out-of-range values at request validation with a 400. Existing bounds: `dto.MaxImageN` for image generation count, `relaycommon.MaxTaskDurationSeconds` for task video duration, `maxTokensLimit` (`relay/helper/valid_request.go`) for `max_tokens`-family fields on every relay format (OpenAI, Claude, Gemini, Responses). Reuse these constants instead of introducing new ad hoc limits for the same concepts. When adding a new relay format or request DTO, bound its max-tokens and count fields in its validator from day one. -- Watch for validation bypass paths: passthrough fields (e.g. `Extra["parameters"]`), task `metadata` maps, and multipart form fields can carry the same quantities around the standard DTO validation. Any adaptor that reads a multiplier from such a path must enforce the same bound (or clamp) locally. +- 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. - 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. @@ -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. - 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`. - 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. - 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. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..92902fb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "bun" + directory: "/web" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5578df..c20540c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} 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: backend: name: Backend vet, build, and test @@ -25,7 +27,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: 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 uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 @@ -34,6 +36,7 @@ jobs: cache-dependency-path: | go.sum relaykit/go.sum + tokenkit/go.sum - name: Create frontend embed placeholder run: | @@ -47,6 +50,10 @@ jobs: working-directory: relaykit run: go vet ./... + - name: Vet tokenkit independently + working-directory: tokenkit + run: go vet ./... + - name: Build root module run: go build ./... @@ -54,7 +61,11 @@ jobs: working-directory: relaykit 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 frontend: @@ -71,7 +82,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: 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 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index 08c57c9..fa0587a 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -39,7 +39,7 @@ jobs: - name: Setup Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version: '>=1.25.1' + go-version: '>=1.26' - name: Build frontend env: @@ -55,13 +55,13 @@ jobs: # if: runner.os != 'Windows' # run: | # 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) if: runner.os == 'Windows' run: | 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 run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4bfed3..c942010 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,16 +44,16 @@ jobs: - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version: '>=1.25.1' + go-version: '>=1.26' - name: Build Backend (amd64) run: | go mod download - go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION + go build -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) run: | sudo apt-get update DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu - CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION + CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -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 run: sha256sum new-api-* > checksums-linux.txt @@ -98,11 +98,11 @@ jobs: - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version: '>=1.25.1' + go-version: '>=1.26' - name: Build Backend run: | 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 run: shasum -a 256 new-api-macos-* > checksums-macos.txt @@ -149,11 +149,11 @@ jobs: - name: Set up Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version: '>=1.25.1' + go-version: '>=1.26' - name: Build Backend run: | go mod download - go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe + go build -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 run: sha256sum new-api-*.exe > checksums-windows.txt diff --git a/AGENTS.md b/AGENTS.md index 7d68eb9..d9abbf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,18 +8,19 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro ## 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 - **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 - **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) ## Architecture - 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. +- `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. - `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/`) - 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/`) - 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. - 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. -- 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. +- 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/`, `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`: diff --git a/CUSTOMIZATION.md b/CUSTOMIZATION.md index 88b6816..8e89ec7 100644 --- a/CUSTOMIZATION.md +++ b/CUSTOMIZATION.md @@ -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 页应用公开展示配置。 - 渠道每 15 分钟探活,展示最近 18 次记录;正常用户请求可恢复分组正常状态。 - 两页共用分组行布局,正常与性能下降分组共同优先,同类状态中支持 Astra 的分组排在前面,其他分组使用紧凑布局;鹈鹕样例按时间倒序横向滚动,最新在左,点击缩略图预览。 -- 鹈鹕检测通过 manxue.ai 访客接口请求公开作品,保存可跳转的作品链接;连接失败时本地生成并标记为不确定。 - 作品脚本在隔离 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 环境变量,也应同步更新。 -其他 Dockerfile.* 和 build-fingerprint-backend.sh 保留为历史构建参考。部分增量构建文件依赖服务器上的旧版镜像或已编译二进制,新环境请使用 Dockerfile.local-release。 +构建需要 Bun 和 Go 1.26;Dockerfile.local-release 包含完整的前端与后端构建过程。 ## 运行与升级 @@ -29,7 +35,6 @@ 升级前备份数据库及 Compose 配置,部署后检查容器健康、版本号、主站分组状态和公开 uptime 页面。Uptime Kuma 的停用与域名反向代理属于部署配置,不会仅因替换应用镜像自动完成。 -公开作品链接受 manxue.ai 的保留期限约束;没有公开链接的历史作品继续使用本站预览。 ## custom.44 current availability diff --git a/Dockerfile b/Dockerfile index 32acfe7..81986bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY ./web ./ COPY ./VERSION /build/VERSION RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build -FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2 +FROM golang:1.26.10-alpine@sha256:9ce231b28998b32e116927c61aa5c406183061d4f3f2d61b292c88e7f6bdf7ca AS builder2 ENV GO111MODULE=on CGO_ENABLED=0 GOWORK=off ARG TARGETOS @@ -18,9 +18,10 @@ ENV GOEXPERIMENT=greenteagc WORKDIR /build ADD go.mod go.sum ./ -# relaykit is a local submodule referenced via replace; its go.mod must be -# present for go mod download to resolve the main module graph. +# relaykit and tokenkit are local submodules referenced via replace; their +# go.mod files must be present for go mod download to resolve the main module graph. ADD relaykit/go.mod ./relaykit/go.mod +ADD tokenkit/go.mod ./tokenkit/go.mod RUN go mod download COPY . . @@ -28,7 +29,7 @@ COPY --from=builder /build/web/dist ./web/dist RUN go test ./service -run TestAppendStreamStatusTreatsClientGoneAsDisplayNormal RUN go test ./controller -run 'TestClientFacing(OpenAIErrorHidesUpstreamDetails|ClaudeErrorHidesUpstreamDetails|ErrorKeepsLocalValidationMessage)' 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 diff --git a/Dockerfile.affinity-switch b/Dockerfile.affinity-switch deleted file mode 100644 index 2e12b92..0000000 --- a/Dockerfile.affinity-switch +++ /dev/null @@ -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" diff --git a/Dockerfile.browser-performance b/Dockerfile.browser-performance deleted file mode 100644 index f8b6166..0000000 --- a/Dockerfile.browser-performance +++ /dev/null @@ -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" diff --git a/Dockerfile.candy-test b/Dockerfile.candy-test deleted file mode 100644 index 992f78e..0000000 --- a/Dockerfile.candy-test +++ /dev/null @@ -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" diff --git a/Dockerfile.ccswitch b/Dockerfile.ccswitch deleted file mode 100644 index 9d2bb83..0000000 --- a/Dockerfile.ccswitch +++ /dev/null @@ -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" diff --git a/Dockerfile.combined b/Dockerfile.combined deleted file mode 100644 index 0cc08c9..0000000 --- a/Dockerfile.combined +++ /dev/null @@ -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" diff --git a/Dockerfile.compact-uptime b/Dockerfile.compact-uptime deleted file mode 100644 index 1ea2e1f..0000000 --- a/Dockerfile.compact-uptime +++ /dev/null @@ -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" diff --git a/Dockerfile.deployed b/Dockerfile.deployed deleted file mode 100644 index dac0392..0000000 --- a/Dockerfile.deployed +++ /dev/null @@ -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" diff --git a/Dockerfile.dev b/Dockerfile.dev index 47475e3..9f2419e 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,7 +1,7 @@ # Backend-only build for frontend development # Skips frontend build and uses a placeholder for //go:embed web/dist -FROM golang:1.26.1-alpine AS builder +FROM golang:1.26.10-alpine@sha256:9ce231b28998b32e116927c61aa5c406183061d4f3f2d61b292c88e7f6bdf7ca AS builder ENV GO111MODULE=on CGO_ENABLED=0 ARG TARGETOS @@ -12,9 +12,10 @@ ENV GOEXPERIMENT=greenteagc WORKDIR /build ADD go.mod go.sum ./ -# relaykit is a local submodule referenced via replace; its go.mod must be -# present for go mod download to resolve the main module graph. +# relaykit and tokenkit are local submodules referenced via replace; their +# go.mod files must be present for go mod download to resolve the main module graph. ADD relaykit/go.mod ./relaykit/go.mod +ADD tokenkit/go.mod ./tokenkit/go.mod RUN go mod download COPY . . diff --git a/Dockerfile.downgrade-rate b/Dockerfile.downgrade-rate deleted file mode 100644 index 1e380c5..0000000 --- a/Dockerfile.downgrade-rate +++ /dev/null @@ -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" diff --git a/Dockerfile.fingerprint-color b/Dockerfile.fingerprint-color deleted file mode 100644 index 068c5d1..0000000 --- a/Dockerfile.fingerprint-color +++ /dev/null @@ -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" diff --git a/Dockerfile.fingerprint-runtime b/Dockerfile.fingerprint-runtime deleted file mode 100644 index 151c21c..0000000 --- a/Dockerfile.fingerprint-runtime +++ /dev/null @@ -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/ diff --git a/Dockerfile.group-degradation b/Dockerfile.group-degradation deleted file mode 100644 index 117bbb6..0000000 --- a/Dockerfile.group-degradation +++ /dev/null @@ -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" diff --git a/Dockerfile.group-pricing b/Dockerfile.group-pricing deleted file mode 100644 index bee6055..0000000 --- a/Dockerfile.group-pricing +++ /dev/null @@ -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 diff --git a/Dockerfile.local-release b/Dockerfile.local-release index 773dc0a..c3dd521 100644 --- a/Dockerfile.local-release +++ b/Dockerfile.local-release @@ -3,9 +3,9 @@ WORKDIR /build/web COPY web/package.json web/bun.lock ./ RUN bun install --frozen-lockfile 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 ARG TARGETOS ARG TARGETARCH @@ -25,7 +25,7 @@ RUN apt-get update \ COPY --from=build /build/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.67-20261004" +LABEL org.opencontainers.image.version="v1.0.0-rc.45-custom.97" EXPOSE 3000 WORKDIR /data ENTRYPOINT ["/new-api"] diff --git a/Dockerfile.meow b/Dockerfile.meow deleted file mode 100644 index 8d1c5d7..0000000 --- a/Dockerfile.meow +++ /dev/null @@ -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"] diff --git a/Dockerfile.modeltrace b/Dockerfile.modeltrace deleted file mode 100644 index b24300c..0000000 --- a/Dockerfile.modeltrace +++ /dev/null @@ -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"] diff --git a/Dockerfile.public-pelican b/Dockerfile.public-pelican deleted file mode 100644 index 26fafe5..0000000 --- a/Dockerfile.public-pelican +++ /dev/null @@ -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" diff --git a/Dockerfile.request-status b/Dockerfile.request-status deleted file mode 100644 index 989cc50..0000000 --- a/Dockerfile.request-status +++ /dev/null @@ -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"] diff --git a/Dockerfile.stream-health b/Dockerfile.stream-health deleted file mode 100644 index b678637..0000000 --- a/Dockerfile.stream-health +++ /dev/null @@ -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" diff --git a/README.en.md b/README.en.md index cd0d551..58b5815 100644 --- a/README.en.md +++ b/README.en.md @@ -4,12 +4,13 @@ # New API -🍥 **Next-Generation Large Model Gateway and AI Asset Management System** +**An AI gateway for models, applications, and agents**

- 中文 | - English | - Français | + 简体中文 | + 繁體中文 | + English | + Français | 日本語

@@ -45,26 +46,54 @@

- Quick Start • - Key Features • - Deployment • - Documentation • - Help + Screenshots • + Capabilities • + Quick start • + Deployment • + Development • + Documentation

+--- + ## 📝 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) -> [!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. > - 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. > - 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. + +--- + + + +## 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 @@ -80,9 +109,6 @@ Peking University - - UCloud - Alibaba Cloud @@ -107,324 +133,174 @@ --- -## 🚀 Quick Start + -### 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. + + + +## 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!"}' +``` + + + +## 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 -# Clone the project git clone https://github.com/QuantumNous/new-api.git cd new-api - -# Edit docker-compose.yml configuration -nano docker-compose.yml - -# Start the service -docker-compose up -d ``` -
-Using Docker Commands +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. ```bash -# Pull the latest image -docker pull calciumion/new-api:latest - -# 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 +docker compose up -d +docker compose logs -f new-api ``` -> **💡 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 -
+| 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] -> 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 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. -📖 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. ---- + -## 📚 Documentation +## Development and extensions -
+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) - -
- -**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:** - -
-View detailed configuration - -**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). - -
- ---- - -## 🤖 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 - -
-View complete interface list - -- [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) - -
- ---- - -## 🚢 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 - -
-Common environment variable configuration - -| 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) - -
- -### 🔧 Deployment Methods - -
-Method 1: Docker Compose (Recommended) +Build the frontend before starting the backend, which embeds `web/dist`: ```bash -# Clone the project -git clone https://github.com/QuantumNous/new-api.git -cd new-api - -# Edit configuration -nano docker-compose.yml - -# Start service -docker-compose up -d +# Repository root +cd web +bun install --frozen-lockfile +bun run build +cd .. +go run . ``` -
+In a second terminal, start the frontend development server: -
-Method 2: Docker Commands - -**Using SQLite:** ```bash -docker run --name new-api -d --restart always \ - -p 3000:3000 \ - -e TZ=Asia/Shanghai \ - -v ./data:/data \ - calciumion/new-api:latest +cd web +bun run dev -- --port 5173 ``` -**Using MySQL:** -```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 -``` +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). -> **💡 Path explanation:** -> - `./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` +| Location | Responsibility | +| --- | --- | +| `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 | -
+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/`. -
-Method 3: BaoTa Panel + -1. Install BaoTa Panel (≥ 9.2.0 version) -2. Search for **New-API** in the application store -3. One-click installation +## Documentation and community -📖 [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 | -
- -### ⚠️ 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 +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. --- @@ -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 | -|------|------| -| 📘 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) | +Additional terms under AGPLv3 Section 7 apply. Modified versions must preserve +the author attribution notice `Frontend design and development by New API +contributors.` in the appropriate legal notices and in any prominent about, +legal, footer, or attribution location presented by the user interface. -### 🤝 Contribution Guide +Modified versions that present a user interface must also preserve a visible +link to the original project: . -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 -- 💡 Propose New Features -- 📝 Improve Documentation -- 🔧 Submit Code +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. --- @@ -484,7 +358,7 @@ Welcome all forms of contribution! 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)** Built with ❤️ by QuantumNous diff --git a/README.fr.md b/README.fr.md index 7870076..583c6bc 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ # 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**

简体中文 | @@ -44,23 +44,34 @@

- Démarrage rapide • - Fonctionnalités clés • - Déploiement • - Documentation • - Aide + Fonctionnalités • + Démarrage rapide • + Déploiement • + Développement • + Documentation

+--- + ## 📝 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] > - 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 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. + + +> [!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 @@ -79,9 +90,6 @@ --> Université de Pékin - UCloud - Alibaba Cloud + +> [!WARNING] +> 本プロジェクトを公衆向け生成 AI サービスまたは API 再販サービスとして運営する場合、ユーザーは届出、コンテンツセキュリティ、本人確認、ログ保持、税務、決済、上流認可などの必要なコンプライアンス義務を先に完了してください。 + --- ## 🤝 信頼できるパートナー @@ -79,9 +90,6 @@ --> 北京大学 - UCloud 優刻得 - Alibaba Cloud + +> [!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. + +--- + + + +## 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 @@ -79,9 +105,6 @@ --> Peking University - UCloud - Alibaba Cloud + +> [!WARNING] +> 将本项目作为面向公众的生成式 AI 服务或 API 转售服务运营时,使用者应先完成备案、内容安全、实名、日志留存、税务、支付和上游授权等合规义务。 + +--- + + + +## 项目截图 + +以下账号、渠道、插件安装状态、用量、价格和费用均为模拟数据,插件市场展示官方目录。点击图片可查看原图。 + +| 数据看板 | 插件市场 | +| --- | --- | +| [![数据看板:模拟请求量、费用和性能指标](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 @@ --> 北京大学 - UCloud 优刻得 - 阿里云 + +> [!WARNING] +> 將本專案作為面向公眾的生成式 AI 服務或 API 轉售服務運營時,使用者應先完成備案、內容安全、實名、日誌留存、稅務、支付和上游授權等合規義務。 + --- ## 🤝 我們信任的合作伙伴 @@ -78,9 +89,6 @@ --> 北京大學 - UCloud 優刻得 - 阿里雲 + diff --git a/web/package.json b/web/package.json index 3f098d2..8b5376d 100644 --- a/web/package.json +++ b/web/package.json @@ -36,25 +36,25 @@ "@hugeicons/core-free-icons": "^4.2.2", "@hugeicons/react": "^1.1.9", "@lezer/highlight": "^1.2.3", - "@lobehub/icons": "^5.10.1", + "@lobehub/icons": "~5.22.0", "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.14.5", "@visactor/react-vchart": "^2.1.2", "@visactor/vchart": "^2.1.2", - "ai": "^7.0.14", + "ai": "^7.0.136", "auto-skeleton-react": "^1.0.5", "axios": "^1.18.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "dayjs": "^1.11.21", - "dompurify": "3.4.13", + "dompurify": "3.4.16", "i18next": "^26.3.4", "i18next-browser-languagedetector": "^8.2.1", "input-otp": "^1.4.2", - "katex": "^0.17.0", + "katex": "^0.18.2", "lucide-react": "^1.23.0", "marked": "^18.0.5", "motion": "^12.42.2", @@ -74,14 +74,14 @@ "shiki": "^4.3.0", "sonner": "^2.0.7", "sse.js": "^2.8.0", - "stream-markdown-parser": "^1.0.9", + "stream-markdown-parser": "^1.2.18", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", "tokenlens": "^1.3.1", "tw-animate-css": "^1.4.0", "use-stick-to-bottom": "^1.1.6", "vaul": "^1.1.2", - "yace": "^1.1.0", + "yace": "^1.1.1", "yaml": "^2.9.0", "zod": "^4.4.3", "zustand": "^5.0.14" @@ -92,7 +92,7 @@ "@rsbuild/plugin-tailwindcss": "^2.0.3", "@tanstack/react-query-devtools": "^5.101.2", "@tanstack/react-router-devtools": "^1.167.0", - "@tanstack/router-plugin": "^1.168.19", + "@tanstack/router-plugin": "^1.168.42", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -105,18 +105,19 @@ "embla-carousel-react": "^8.6.0", "jsdom": "^29.1.1", "knip": "^6.24.0", - "oxfmt": "^0.57.0", + "oxfmt": "^0.72.0", "oxlint": "^1.72.0", "shadcn": "^4.12.0", "vitest": "^4.1.10" }, "overrides": { "brace-expansion": "2.1.1", - "dompurify": "3.4.13", + "dompurify": "3.4.16", "fast-uri": "3.1.2", "hono": "4.12.22", "ip-address": "10.2.0", "js-cookie": "3.0.7", + "katex": "0.18.2", "mermaid": "11.15.0", "minimist": "1.2.8", "postcss": "8.5.15", diff --git a/web/public/custom-site-icon.png b/web/public/custom-site-icon.png new file mode 100644 index 0000000..bc17c58 Binary files /dev/null and b/web/public/custom-site-icon.png differ diff --git a/web/public/favicon.ico b/web/public/favicon.ico index ab5f17b..7a480b2 100644 Binary files a/web/public/favicon.ico and b/web/public/favicon.ico differ diff --git a/web/public/logo.png b/web/public/logo.png index 851556f..918700c 100644 Binary files a/web/public/logo.png and b/web/public/logo.png differ diff --git a/web/rsbuild.config.ts b/web/rsbuild.config.ts index e0d9c25..2c2fd3f 100644 --- a/web/rsbuild.config.ts +++ b/web/rsbuild.config.ts @@ -14,6 +14,9 @@ export default defineConfig(({ envMode }) => { process.env.VITE_REACT_APP_SERVER_URL || env.rawPublicVars.VITE_REACT_APP_SERVER_URL || 'http://localhost:3000' + const siteFavicon = + process.env.VITE_REACT_APP_FAVICON || + env.rawPublicVars.VITE_REACT_APP_FAVICON const isProd = envMode === 'production' const devProxy = Object.fromEntries( @@ -53,6 +56,11 @@ export default defineConfig(({ envMode }) => { }, }, source: { + define: { + 'import.meta.env.VITE_REACT_APP_FAVICON': JSON.stringify( + siteFavicon || '' + ), + }, entry: { index: './src/main.tsx', }, @@ -64,6 +72,7 @@ export default defineConfig(({ envMode }) => { }, html: { template: './index.html', + favicon: siteFavicon ? `./public${siteFavicon}` : './public/favicon.ico', }, server: { host: '0.0.0.0', diff --git a/web/src/assets/brand-icons/icon-alipay.tsx b/web/src/assets/brand-icons/icon-alipay.tsx new file mode 100644 index 0000000..07666a0 --- /dev/null +++ b/web/src/assets/brand-icons/icon-alipay.tsx @@ -0,0 +1,38 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { SVGProps } from 'react' + +import { cn } from '@/lib/utils' + +export function IconAlipay({ className, ...props }: SVGProps) { + return ( + + Alipay + + + ) +} diff --git a/web/src/assets/brand-icons/index.ts b/web/src/assets/brand-icons/index.ts index 088a8c8..c679d18 100644 --- a/web/src/assets/brand-icons/index.ts +++ b/web/src/assets/brand-icons/index.ts @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +export { IconAlipay } from './icon-alipay' export { IconDiscord } from './icon-discord' export { IconDocker } from './icon-docker' export { IconFacebook } from './icon-facebook' diff --git a/web/src/components/__tests__/command-menu.test.tsx b/web/src/components/__tests__/command-menu.test.tsx new file mode 100644 index 0000000..0f51a57 --- /dev/null +++ b/web/src/components/__tests__/command-menu.test.tsx @@ -0,0 +1,130 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from '@tanstack/react-router' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' + +import { SearchProvider } from '@/context/search-provider' +import { ROLE } from '@/lib/roles' +import { STATUS_QUERY_KEY } from '@/lib/status-query' +import { useAuthStore } from '@/stores/auth-store' + +import { CommandMenu } from '../command-menu' +import { Search } from '../search' + +const adminEntries = ['Channels', 'Models', 'Users', 'Redemption Codes'] +const superAdminEntries = ['System Info', 'Task Plugins', 'System Settings'] + +// jsdom lacks the Web Animations API the menu's scroll area waits on. +const getAnimationsDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'getAnimations' +) + +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'getAnimations', { + configurable: true, + value: () => [], + }) +}) + +afterAll(() => { + if (getAnimationsDescriptor) { + Object.defineProperty( + HTMLElement.prototype, + 'getAnimations', + getAnimationsDescriptor + ) + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'getAnimations') + } +}) + +afterEach(() => { + useAuthStore.setState(useAuthStore.getInitialState(), true) + window.localStorage.clear() +}) + +async function searchEntriesFor(role: number, sidebarModulesAdmin = '') { + useAuthStore.getState().auth.setUser({ id: 1, username: 'alice', role }) + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + client.setQueryData(STATUS_QUERY_KEY, { + SidebarModulesAdmin: sidebarModulesAdmin, + }) + const router = createRouter({ + routeTree: createRootRoute({ + component: () => ( + + + + + ), + }), + history: createMemoryHistory({ initialEntries: ['/dashboard/overview'] }), + }) + await router.load() + render( + + + + ) + + await userEvent.click(await screen.findByRole('button', { name: 'Search' })) + const options = await screen.findAllByRole('option') + return options.map((option) => option.textContent) +} + +test('regular users only find their own console pages in search', async () => { + const entries = await searchEntriesFor(ROLE.USER) + + expect(entries).toEqual( + expect.arrayContaining(['API Keys', 'Usage Logs', 'Wallet', 'Profile']) + ) + for (const entry of [...adminEntries, ...superAdminEntries]) { + expect(entries).not.toContain(entry) + } +}) + +test('admins find admin pages in search but not super admin pages', async () => { + const entries = await searchEntriesFor(ROLE.ADMIN) + + expect(entries).toEqual(expect.arrayContaining(adminEntries)) + for (const entry of superAdminEntries) { + expect(entries).not.toContain(entry) + } +}) + +test('pages hidden in the sidebar settings stay out of search', async () => { + const entries = await searchEntriesFor( + ROLE.USER, + JSON.stringify({ personal: { enabled: true, topup: false } }) + ) + + expect(entries).not.toContain('Wallet') + expect(entries).toContain('Profile') +}) diff --git a/web/src/components/__tests__/date-picker-locale.test.tsx b/web/src/components/__tests__/date-picker-locale.test.tsx new file mode 100644 index 0000000..467f709 --- /dev/null +++ b/web/src/components/__tests__/date-picker-locale.test.tsx @@ -0,0 +1,74 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import i18next from 'i18next' +import { afterEach, expect, test, vi } from 'vitest' + +import { DatePicker } from '../date-picker' +import { DateTimePicker } from '../datetime-picker' + +vi.mock('@/components/ui/calendar', () => ({ + Calendar: ({ locale }: { locale?: { code?: string } }) => ( +
{locale?.code ?? 'missing'}
+ ), +})) + +const selected = new Date('2026-10-11T00:00:00') + +async function setInterfaceLanguage(language: string) { + if (!i18next.hasResourceBundle(language, 'translation')) { + i18next.addResourceBundle(language, 'translation', {}) + } + await act(() => i18next.changeLanguage(language)) +} + +async function openCalendar() { + await userEvent.click(screen.getByRole('button', { name: /2026-10-11/ })) + return screen.findByTestId('calendar-locale') +} + +afterEach(async () => { + await act(() => i18next.changeLanguage('en')) +}) + +test('uses Chinese calendar locales for zhCN and zhTW in both date pickers', async () => { + await setInterfaceLanguage('zhCN') + const datePicker = render( + undefined} /> + ) + expect(await openCalendar()).toHaveTextContent('zh-CN') + datePicker.unmount() + + const dateTimePicker = render( + undefined} /> + ) + expect(await openCalendar()).toHaveTextContent('zh-CN') + dateTimePicker.unmount() + + await setInterfaceLanguage('zhTW') + render( undefined} />) + expect(await openCalendar()).toHaveTextContent('zh-TW') +}) + +test('keeps French on the French calendar locale', async () => { + await setInterfaceLanguage('fr') + render( undefined} />) + expect(await openCalendar()).toHaveTextContent('fr') +}) diff --git a/web/src/components/__tests__/datetime-picker-time.test.tsx b/web/src/components/__tests__/datetime-picker-time.test.tsx new file mode 100644 index 0000000..d42a1f4 --- /dev/null +++ b/web/src/components/__tests__/datetime-picker-time.test.tsx @@ -0,0 +1,74 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { fireEvent, render, screen } from '@testing-library/react' +import { expect, test, vi } from 'vitest' + +import { DateTimePicker } from '../datetime-picker' + +test('shows the seconds of the selected time', () => { + render( + undefined} + /> + ) + expect(screen.getByLabelText('Time')).toHaveValue('08:30:45') +}) + +test('keeps the seconds typed into the time field', () => { + const onChange = vi.fn() + render( + + ) + fireEvent.change(screen.getByLabelText('Time'), { + target: { value: '08:30:45' }, + }) + expect(onChange).toHaveBeenLastCalledWith(new Date(2026, 9, 11, 8, 30, 45)) +}) + +test('reads a time without seconds as zero seconds', () => { + const onChange = vi.fn() + render( + + ) + fireEvent.change(screen.getByLabelText('Time'), { + target: { value: '09:15' }, + }) + expect(onChange).toHaveBeenLastCalledWith(new Date(2026, 9, 11, 9, 15, 0)) +}) + +test('keeps the current time when the time field is emptied', () => { + const onChange = vi.fn() + render( + + ) + const field = screen.getByLabelText('Time') + fireEvent.change(field, { target: { value: '' } }) + expect(onChange).not.toHaveBeenCalled() + expect(field).toHaveValue('20:30:45') +}) diff --git a/web/src/components/ai-elements/__tests__/code-block-editor.test.tsx b/web/src/components/ai-elements/__tests__/code-block-editor.test.tsx index 10c7c49..e4b5a6b 100644 --- a/web/src/components/ai-elements/__tests__/code-block-editor.test.tsx +++ b/web/src/components/ai-elements/__tests__/code-block-editor.test.tsx @@ -40,6 +40,25 @@ function editorTree(value: string) { } describe('CodeBlockEditor', () => { + test('does not add a second vertical inset to the line-number gutter for multiline source', () => { + const { getByRole } = render( + undefined} + value={'2\n1\n1'} + /> + ) + + const editor = getByRole('textbox', { name: 'Plugin source' }) + const gutters = editor.querySelector('.cm-gutters') + + // CodeMirror already offsets each gutter line by the content padding. + // Extra vertical padding here shifts every number below its source line. + expect(gutters).toHaveStyle({ paddingTop: '0px', paddingBottom: '0px' }) + }) + test('keeps the same editor instance when value and onKeyDown change on rerender', () => { const { rerender } = render(editorTree('h')) diff --git a/web/src/components/ai-elements/code-block.tsx b/web/src/components/ai-elements/code-block.tsx index 4abf557..7431149 100644 --- a/web/src/components/ai-elements/code-block.tsx +++ b/web/src/components/ai-elements/code-block.tsx @@ -161,7 +161,8 @@ const codeMirrorTheme = EditorView.theme({ fontFamily: 'var(--font-mono)', fontSize: '13px', lineHeight: '1.5rem', - padding: '1rem 1rem 1rem 0', + // CodeMirror already accounts for the content's vertical padding. + padding: '0 1rem 0 0', }, '.cm-gutters:empty': { display: 'none', diff --git a/web/src/components/ai-elements/prompt-input.tsx b/web/src/components/ai-elements/prompt-input.tsx index 46ab3f6..e232b98 100644 --- a/web/src/components/ai-elements/prompt-input.tsx +++ b/web/src/components/ai-elements/prompt-input.tsx @@ -889,7 +889,7 @@ export const PromptInputTextarea = ({ return ( setIsComposing(false)} onCompositionStart={() => setIsComposing(true)} diff --git a/web/src/components/auto-group-order-item.tsx b/web/src/components/auto-group-order-item.tsx new file mode 100644 index 0000000..731fe16 --- /dev/null +++ b/web/src/components/auto-group-order-item.tsx @@ -0,0 +1,136 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { + ArrowDown01Icon, + ArrowUp01Icon, + Cancel01Icon, + Drag01Icon, +} from '@hugeicons/core-free-icons' +import { HugeiconsIcon } from '@hugeicons/react' +import { Reorder, useDragControls } from 'motion/react' +import type { KeyboardEvent, PointerEvent, ReactNode } from 'react' +import { useTranslation } from 'react-i18next' + +import { Button } from '@/components/ui/button' + +type AutoGroupOrderItemProps = { + group: string + index: number + count: number + leading?: ReactNode + children?: ReactNode + onMove: (index: number, direction: 'up' | 'down') => void + onRemove: (group: string) => void +} + +export function AutoGroupOrderItem(props: AutoGroupOrderItemProps) { + const { t } = useTranslation() + const dragControls = useDragControls() + + const handleDragStart = (event: PointerEvent) => { + dragControls.start(event) + } + + const handleDragKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowUp') { + event.preventDefault() + props.onMove(props.index, 'up') + } + if (event.key === 'ArrowDown') { + event.preventDefault() + props.onMove(props.index, 'down') + } + } + + return ( + + + {props.leading} +
+ + {props.group} + + {props.children} +
+
+ + + +
+
+ ) +} diff --git a/web/src/components/command-menu.tsx b/web/src/components/command-menu.tsx index 0f4d320..015a3e3 100644 --- a/web/src/components/command-menu.tsx +++ b/web/src/components/command-menu.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useLocation, useNavigate } from '@tanstack/react-router' +import { useNavigate } from '@tanstack/react-router' import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react' import React from 'react' import { useTranslation } from 'react-i18next' @@ -33,9 +33,8 @@ import { } from '@/components/ui/command' import { useSearch } from '@/context/search-provider' import { useTheme } from '@/context/theme-provider' -import { useSidebarData } from '@/hooks/use-sidebar-data' +import { useSidebarView } from '@/hooks/use-sidebar-view' -import { getNavGroupsForPath } from './layout/lib/sidebar-view-registry' import { ScrollArea } from './ui/scroll-area' export function CommandMenu() { @@ -43,12 +42,9 @@ export function CommandMenu() { const navigate = useNavigate() const { setTheme } = useTheme() const { open, setOpen } = useSearch() - const { pathname } = useLocation() - const sidebarData = useSidebarData() - - // Use the active nested sidebar view's nav groups when one matches - // the current URL; otherwise fall back to the root navigation. - const navGroups = getNavGroupsForPath(pathname, t) ?? sidebarData.navGroups + // Offer the same pages as the sidebar, so search never lists pages the + // user's role or the sidebar settings hide. + const { navGroups } = useSidebarView() const runCommand = React.useCallback( (command: () => unknown) => { @@ -67,11 +63,11 @@ export function CommandMenu() { {t('No results found.')} {navGroups.map((group) => ( - {group.items.map((navItem, i) => { - if (navItem.url) + {group.items.map((navItem) => { + if (navItem.url) { return ( { runCommand(() => navigate({ to: navItem.url })) @@ -83,10 +79,11 @@ export function CommandMenu() { {navItem.title} ) + } - return navItem.items?.map((subItem, i) => ( + return navItem.items?.map((subItem) => ( { runCommand(() => navigate({ to: subItem.url })) diff --git a/web/src/components/config-drawer.tsx b/web/src/components/config-drawer.tsx index 6e0d18c..bd978eb 100644 --- a/web/src/components/config-drawer.tsx +++ b/web/src/components/config-drawer.tsx @@ -54,6 +54,7 @@ import { useThemeCustomization } from '@/context/theme-customization-provider' import { useTheme } from '@/context/theme-provider' import { type ContentLayout, + resolveThemeFont, THEME_PRESETS, type ThemeFont, type ThemePreset, @@ -305,28 +306,26 @@ function PresetConfig() { /** * Font options shown in the theme drawer. * - * Each option renders a live "Aa" preview in the font it represents. - * `Auto` deliberately leaves `fontFamily` undefined so the preview inherits - * the currently active body font — that way the user sees what `Auto` will - * actually look like for the active preset (Anthropic → serif glyphs, - * everything else → sans glyphs) without us having to duplicate the - * preset-default mapping in the UI. + * Each option previews the font it will select, including Auto when the + * user currently has an explicit font selected. */ const FONT_OPTIONS: { value: ThemeFont label: string - // CSS font-family applied to the "Aa" preview. `undefined` = inherit - // from the current theme (used by the `default` option). + // Auto resolves its preview from the active preset. preview?: string }[] = [ { value: 'default', label: 'Auto', preview: undefined }, - { value: 'sans', label: 'Sans', preview: 'var(--font-sans)' }, + { value: 'sans', label: 'Sans', preview: 'var(--font-public-sans)' }, { value: 'serif', label: 'Serif', preview: 'var(--font-serif)' }, ] function FontConfig() { const { t } = useTranslation() const { defaults, customization, setFont } = useThemeCustomization() + const autoFont = resolveThemeFont('default', customization.preset) + const autoFontFamily = + autoFont === 'serif' ? 'var(--font-serif)' : 'sans-serif' return (
diff --git a/web/src/components/model-group-selector/__tests__/group-detail.test.ts b/web/src/components/model-group-selector/__tests__/group-detail.test.ts new file mode 100644 index 0000000..524092a --- /dev/null +++ b/web/src/components/model-group-selector/__tests__/group-detail.test.ts @@ -0,0 +1,56 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { createInstance } from 'i18next' +import { describe, expect, test } from 'vitest' + +import { formatGroupDetail } from '../group-detail' + +describe('model group selector group detail', () => { + test('translates the backend description key and the auto ratio when the language is Chinese', async () => { + const i18n = createInstance() + await i18n.init({ + lng: 'zh', + resources: { + zh: { + translation: { + 'User group': '用户分组', + Auto: '自动', + 'Ratio: {{value}}': '倍率:{{value}}', + }, + }, + }, + }) + + expect( + formatGroupDetail({ desc: 'User group', ratio: 'auto' }, i18n.t) + ).toBe('用户分组 · 倍率:自动') + }) + + test('keeps an administrator description and a numeric ratio as written', async () => { + const i18n = createInstance() + await i18n.init({ lng: 'en', resources: { en: { translation: {} } } }) + + expect(formatGroupDetail({ desc: 'VIP 专线', ratio: 1.5 }, i18n.t)).toBe( + 'VIP 专线 · Ratio: 1.5' + ) + expect(formatGroupDetail({ description: 'No ratio' }, i18n.t)).toBe( + 'No ratio' + ) + }) +}) diff --git a/web/src/components/model-group-selector/group-detail.ts b/web/src/components/model-group-selector/group-detail.ts new file mode 100644 index 0000000..8d77222 --- /dev/null +++ b/web/src/components/model-group-selector/group-detail.ts @@ -0,0 +1,42 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { translateServerText } from '@/lib/server-error-message' + +export interface GroupDetailSource { + ratio?: number | string + desc?: string + description?: string +} + +/** + * The line under a group name: its description and billing ratio. Built-in + * descriptions arrive as English source keys, and the auto group has a + * non-numeric ratio. + */ +export function formatGroupDetail( + group: GroupDetailSource, + t: (key: string, options?: Record) => string +): string { + const description = group.desc + ? translateServerText(t, group.desc) + : (group.description ?? '') + if (!group.ratio) return description + const value = typeof group.ratio === 'number' ? group.ratio : t('Auto') + return `${description} · ${t('Ratio: {{value}}', { value })}` +} diff --git a/web/src/components/multi-select.tsx b/web/src/components/multi-select.tsx index 30f4376..89e2cc2 100644 --- a/web/src/components/multi-select.tsx +++ b/web/src/components/multi-select.tsx @@ -47,6 +47,11 @@ export type Option = { * values also carry a marker icon whose tooltip repeats the hint. */ hint?: string + /** + * Leading icon rendered before the label in the dropdown and on the chip. + * Decorative only: it never changes the accessible name. + */ + icon?: React.ReactNode } interface MultiSelectProps { @@ -153,6 +158,14 @@ export function MultiSelect(props: MultiSelectProps) { return map }, [props.options]) + const iconMap = React.useMemo(() => { + const map = new Map() + for (const option of props.options) { + if (option.icon) map.set(option.value, option.icon) + } + return map + }, [props.options]) + const trimmedInput = inputValue.trim() const inputMatchesExisting = trimmedInput.length > 0 && @@ -316,8 +329,17 @@ export function MultiSelect(props: MultiSelectProps) { {visibleValues.map((value) => { const label = labelMap.get(value) ?? value const hint = hintMap.get(value) + const icon = iconMap.get(value) return ( + {icon && ( + + )} {props.copyChipOnClick ? ( + + +
+ {group.resources.map((resource) => ( + + ))} +
+
+ + + ) + })} + + ) +} + +function PermissionResourceRow(props: PermissionResourceRowProps) { + const { t } = useTranslation() + const resource = props.resource + const label = t(resource.label_key) + return ( +
+
+ {label} + + + } + > + + +
+ {resource.actions.map((option) => ( +
+
{t(option.label_key)}
+
{t(option.description_key)}
+
+ ))} +
+
+
+
+
+ {resource.actions.map((option) => { + const pressed = + props.value[resource.resource]?.[option.action] === true + const descriptionId = `${props.idPrefix}-${resource.resource}-${option.action}` + return ( + { + props.onChange({ + ...props.value, + [resource.resource]: { + ...props.value[resource.resource], + [option.action]: next, + }, + }) + }} + className={cn( + 'h-auto min-h-7 max-w-full font-normal whitespace-normal', + pressed && + 'border-primary bg-primary/10! text-primary hover:bg-primary/15' + )} + > + {pressed && + ) + })} +
+
+ ) +} diff --git a/web/src/components/ui/__tests__/dialog-layout.test.tsx b/web/src/components/ui/__tests__/dialog-layout.test.tsx new file mode 100644 index 0000000..16822b4 --- /dev/null +++ b/web/src/components/ui/__tests__/dialog-layout.test.tsx @@ -0,0 +1,107 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, test, vi } from 'vitest' + +import { ConfirmDialog } from '@/components/confirm-dialog' +import { Dialog } from '@/components/dialog' +import { Button } from '@/components/ui/button' + +import { Dialog as DialogRoot, DialogContent, DialogTitle } from '../dialog' + +describe('dialog viewport layout', () => { + test('long content shrinks independently of a stacked action footer', async () => { + const onConfirm = vi.fn() + render( + + + + + + } + > +

Long release notes

+
+ ) + const dialog = screen.getByRole('dialog', { name: 'System updates' }) + // Keep a dynamic-viewport fallback when global CSS variables are missing. + expect(dialog).toHaveClass( + 'max-h-[var(--dialog-available-height,calc(100dvh-2rem))]' + ) + expect(dialog).toHaveClass('top-[var(--dialog-viewport-center,50dvh)]') + // If header and footer alone exceed a short viewport, the popup itself + // must remain scrollable instead of clipping its actions. + expect(dialog).toHaveClass('overflow-y-auto', 'overscroll-contain') + const body = + screen.getByText('Long release notes').parentElement?.parentElement + expect(body).toHaveClass('min-h-0', 'overflow-y-auto') + expect(body).not.toHaveClass('max-h-[calc(100vh-14rem)]') + const footer = dialog.querySelector('[data-slot=dialog-footer]') + expect(footer).toHaveClass('flex-shrink-0') + expect(body?.contains(footer)).toBe(false) + await userEvent.click(screen.getByRole('button', { name: 'Check again' })) + expect(onConfirm).toHaveBeenCalledOnce() + }) + + test('unwrapped dialogs bound and scroll long content within the visible viewport', () => { + render( + + + Long dialog +

Long content

+
+
+ ) + expect(screen.getByRole('dialog', { name: 'Long dialog' })).toHaveClass( + 'top-[var(--dialog-viewport-center,50dvh)]', + 'max-h-[var(--dialog-available-height,calc(100dvh-2rem))]', + 'overflow-y-auto', + 'overscroll-contain' + ) + }) + + test('confirmation dialogs scroll overflowing content and keep confirmation usable', async () => { + const onConfirm = vi.fn() + render( + {}} + title='Confirm changes' + desc='A long confirmation description' + handleConfirm={onConfirm} + /> + ) + expect( + screen.getByRole('alertdialog', { name: 'Confirm changes' }) + ).toHaveClass( + 'top-[var(--dialog-viewport-center,50dvh)]', + 'max-h-[var(--dialog-available-height,calc(100dvh-2rem))]', + 'overflow-y-auto', + 'overscroll-contain' + ) + await userEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onConfirm).toHaveBeenCalledOnce() + }) +}) diff --git a/web/src/components/ui/__tests__/markdown.test.tsx b/web/src/components/ui/__tests__/markdown.test.tsx new file mode 100644 index 0000000..6d4171a --- /dev/null +++ b/web/src/components/ui/__tests__/markdown.test.tsx @@ -0,0 +1,68 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { render } from '@testing-library/react' +import { expect, test } from 'vitest' + +import { Markdown } from '../markdown' + +test.each([ + ['inline', 'Formula: $$\\frac{1}{2}$$.'], + ['display', '$$\n\\frac{1}{2}\n$$'], + ['fenced', '```math\n\\frac{1}{2}\n```'], +])('preserves MathML and visual output when rendering %s math', (_, source) => { + const view = render({source}) + const math = view.getByRole('math', { hidden: true }) + + expect(math.querySelector('mfrac')).not.toBeNull() + expect(math.querySelector('annotation')).toHaveTextContent('\\frac{1}{2}') + expect(view.container.querySelector('.katex-html')).toHaveAttribute( + 'aria-hidden', + 'true' + ) +}) + +test('does not load math resources when trust is inherited from a polluted prototype', () => { + const originalTrust = Object.getOwnPropertyDescriptor( + Object.prototype, + 'trust' + ) + + try { + Object.defineProperty(Object.prototype, 'trust', { + configurable: true, + value: true, + writable: true, + }) + + const view = render( + + {'$$\\includegraphics{https://example.com/untrusted.png}$$'} + + ) + + expect(view.container.querySelector('img')).toBeNull() + expect(view.container).toHaveTextContent('\\includegraphics') + } finally { + if (originalTrust) { + Object.defineProperty(Object.prototype, 'trust', originalTrust) + } else { + Reflect.deleteProperty(Object.prototype, 'trust') + } + } +}) diff --git a/web/src/components/ui/alert-dialog.tsx b/web/src/components/ui/alert-dialog.tsx index ae07eba..3aaf075 100644 --- a/web/src/components/ui/alert-dialog.tsx +++ b/web/src/components/ui/alert-dialog.tsx @@ -70,7 +70,7 @@ function AlertDialogContent({ data-slot='alert-dialog-content' data-size={size} className={cn( - 'group/alert-dialog-content bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm', + 'group/alert-dialog-content bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-[var(--dialog-viewport-center,50dvh)] left-1/2 z-50 grid max-h-[var(--dialog-available-height,calc(100dvh-2rem))] w-full overflow-y-auto overscroll-contain -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 ring-1 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm', className )} {...props} diff --git a/web/src/components/ui/combobox-input.tsx b/web/src/components/ui/combobox-input.tsx index 2cd23d1..3db52b6 100644 --- a/web/src/components/ui/combobox-input.tsx +++ b/web/src/components/ui/combobox-input.tsx @@ -39,7 +39,7 @@ interface ComboboxInputProps { placeholder?: string emptyText?: string className?: string - dropdownClassName?: string + popupClassName?: string id?: string allowCustomValue?: boolean openOnFocus?: boolean @@ -57,7 +57,7 @@ export function ComboboxInput({ placeholder = 'Select or type...', emptyText = 'No option found.', className, - dropdownClassName, + popupClassName, id, allowCustomValue = false, openOnFocus = true, @@ -297,7 +297,7 @@ export function ComboboxInput({ }} className={cn( 'bg-popover text-popover-foreground z-100 rounded-md border shadow-md', - dropdownClassName + popupClassName )} > {filteredOptions.length > 0 ? ( diff --git a/web/src/components/ui/combobox.tsx b/web/src/components/ui/combobox.tsx index 37d1650..f58dfa0 100644 --- a/web/src/components/ui/combobox.tsx +++ b/web/src/components/ui/combobox.tsx @@ -50,7 +50,7 @@ type LegacyComboboxProps = { allowCustomValue?: boolean showSelectedIcon?: boolean className?: string - dropdownClassName?: string + popupClassName?: string id?: string openOnFocus?: boolean disabled?: boolean @@ -88,7 +88,7 @@ function Combobox( placeholder={props.searchPlaceholder ?? props.placeholder} emptyText={props.emptyText} className={props.className} - dropdownClassName={props.dropdownClassName} + popupClassName={props.popupClassName} allowCustomValue={props.allowCustomValue} openOnFocus={props.openOnFocus} /> @@ -160,7 +160,7 @@ function OptionCombobox(props: LegacyComboboxProps) { )} - + {props.emptyText ?? t('No results found')} diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 3fa548b..5c278b2 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -70,7 +70,7 @@ function DialogContent({ . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { THEME_STORAGE_KEYS } from '@/lib/theme-storage' + +// The inline script in index.html must apply the theme class before React +// mounts; Dark Reader checks `html.dark` only once while the page loads. +const html = readFileSync( + resolve(import.meta.dirname, '../../../index.html'), + 'utf8' +) +const bootScript = html.match(/