Compare commits

...

37 Commits

Author SHA1 Message Date
QuentinHsu f5753a2b31 perf(web): simplify public page hero copy 2026-06-06 15:49:38 +08:00
同語 adc390c5fb feat(web): show user id on profile page (#5317)
Merge pull request #5317 from P2K0/feat/profile-show-user-id
2026-06-06 00:45:13 +08:00
xujiantop-crypto 32805849d6 fix: reuse stream scanner buffer in channel handlers (#5225) 2026-06-05 12:18:57 +08:00
Don Ganesh 01c2128e23 fix: 收窄 OpenAI o 系列模型适配范围 (#5293)
* fix: 收窄 OpenAI o 系列模型适配范围

* fix(openai): 限制 gpt-5 适配仅作用于 OpenAI 模型

* fix(openai): narrow o-series reasoning model detection

---------

Co-authored-by: Seefs <i@seefs.me>
2026-06-05 12:12:45 +08:00
QuentinHsu 189913b7a0 fix(i18n): clarify thinking adapter copy (#5242)
- update the global thinking blacklist label to describe skipped suffix processing instead of disabled model thinking.
- rename Claude and Gemini adapter labels to thinking suffix adapter and sync all default locales.
- revise Claude helper text to clarify suffix request adaptation while keeping billing predictable.
2026-06-05 11:54:57 +08:00
Seefs d2f7f9ee3a fix: limit anonymous request body (#5244)
* fix: limit anonymous request body (env ANONYMOUS_REQUEST_BODY_LIMIT_KB = 512)

* fix: allow disabling anonymous request body limit
2026-06-05 11:39:29 +08:00
Chen011214 83068d115e fix(relay): fix Anthropic-compatible compatibility for GLM (avoid chunked encoding) (#5307) 2026-06-05 11:31:20 +08:00
XiaoDingSiRen 4a188deeaa feat: 支持配置渠道被禁用后是否清空渠道粘性 (#5306)
* fix: evict stale channel affinity

* feat: configure disabled channel affinity retention

---------

Co-authored-by: Seefs <i@seefs.me>
2026-06-05 11:30:29 +08:00
Seefs 933ea0cddc fix: add relay idle connection timeout config (#5309) 2026-06-05 11:30:08 +08:00
P2K0 b53319361f feat(web): show user id on profile page 2026-06-05 07:37:02 +08:00
Rain 87cc22d7ec fix(distributor): resolve model for GET /v1/video/generations/:task_id (#5133) 2026-06-04 18:48:30 +08:00
Rain 3aa113b5a3 fix(dify): initialize file pointer before remote-image field assignment (#5134) 2026-06-04 18:21:35 +08:00
同語 00d23abf64 fix: 修复余额显示时只切换了单位未切换数值 #5296
Merge pull request #5296 from feitianbubu/pr/27fe9a3a82f51bac2b7645213e3b1480cb7f14f2
2026-06-04 02:55:23 +08:00
feitianbubu 580ad97c02 fix: convert usd amount by exchange rate in classic quota display 2026-06-03 22:23:12 +08:00
t0ng7u b0ac0429cf fix(web): resolve TypeScript errors in usage logs mobile card
Cast row.original to Record<string, unknown> before accessing created_at and type in CommonLogsCard, matching the pattern used elsewhere in the same component.

Close: #5243
2026-06-03 12:37:36 +08:00
Seefs d17b566bcc docs: refine issue templates (#5271) 2026-06-03 12:04:40 +08:00
同語 7aaa533265 fix(channels): reveal advanced validation errors #5239
Merge pull request #5239 from QuantumNous/fix/channel-advanced-errors
2026-06-02 14:30:20 +08:00
t0ng7u 7791b78429 chore(fd): delete the test file 2026-06-02 14:28:35 +08:00
QuentinHsu cb5c0453f5 fix(channels): avoid expanding advanced settings for model mapping
- remove model mapping from advanced settings error detection so visible model configuration errors do not expand the advanced panel.
- update the edit-time advanced settings auto-expand check to only depend on fields actually rendered in the advanced section.
- add regression coverage to verify model_mapping errors are not classified as advanced settings errors.
2026-06-02 12:31:32 +08:00
QuentinHsu 4d20e053cb fix(channels): reveal advanced validation errors
- add channel form error detection for JSON validation errors inside advanced settings.
- expand advanced settings on invalid channel drawer submit and prompt users to fix highlighted fields.
- add regression tests for advanced error detection, non-advanced exclusions, and schema error classification.
2026-06-02 12:09:47 +08:00
同語 0ff9c35e62 feat(web): support classic Rsbuild dev and build
Merge pull request #5232 from QuantumNous/feat/classic-rsbuild-dev-workflow
2026-06-02 11:33:33 +08:00
QuentinHsu 0bbcaa8999 fix(classic): inject Semi React 19 adapter 2026-06-02 00:50:29 +08:00
QuentinHsu 1e9ff8a0de feat(web): support classic Rsbuild dev and build
- migrate the classic frontend from Vite to Rsbuild with JSX, Semi UI, proxy, and production build config.
- update make dev-web to run both default and classic frontends for local theme switching.
- fix classic public page height, footer, CORS proxy, error handling, and constant export warnings.
- update Dockerfile and release workflow to install from the web workspace root with the shared lockfile.
2026-06-02 00:32:16 +08:00
同語 9a2e60dff2 chore(web): centralize shared frontend dependency versions #5227
Merge pull request #5227 from QuantumNous/chore/web-shared-dependency-catalog
2026-06-01 19:19:13 +08:00
QuentinHsu b596de739d chore(web): centralize shared frontend dependency versions
- add a web workspace catalog to manage dependency versions shared by default and classic frontends.
- switch shared dependencies including @lobehub/icons to catalog references and align @lobehub/icons on 5.10.0.
- replace separate frontend Bun lockfiles with a unified web/bun.lock to reduce duplicate maintenance.
2026-06-01 19:12:39 +08:00
同語 45d54c1613 fix(pricing): sync custom model icons #5224
Merge pull request #5224 from QuantumNous/fix/pricing-model-icons
2026-06-01 18:17:58 +08:00
QuentinHsu 086044650d fix(pricing): sync custom model icons
- add the icon field to the pricing model type to consume model-level icons returned by the backend.
- prefer model icons in cards, table model cells, and detail headers while falling back to vendor icons.
2026-06-01 17:58:02 +08:00
GGXH 0c7aceb831 feat: add claude opus 4.8 support (#5177) 2026-05-31 13:50:52 +08:00
dependabot[bot] b2e25b7df2 chore(deps): bump axios from 1.15.2 to 1.16.0 in /web/classic (#5185)
Bumps [axios](https://github.com/axios/axios) from 1.15.2 to 1.16.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.2...v1.16.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.16.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 13:49:50 +08:00
skynono 230a3592f8 perf: order admin logs by created_at to use composite index (#5116) 2026-05-30 20:00:02 +08:00
YuPeng Wu afb470e405 fix(model): correct idx_created_at_id index column order to (created_at, id) (#5191)
The idx_created_at_id composite index on the logs table was defined as
(id, created_at) because the GORM `priority` values on Id and CreatedAt were
swapped. Since `id` is the auto-increment primary key, a secondary composite
index leading with `id` is redundant with the PK and cannot accelerate
`created_at` range scans (a range column must sit at the index prefix).

This defeats the common log-listing queries
(`WHERE created_at BETWEEN ? AND ? ORDER BY id DESC LIMIT n` in
GetAllLogs/GetUserLogs) that the index name implies it should serve — the
optimizer falls back to scanning the primary key, degrading to near full-table
scans on large logs tables.

Swap the priorities so the column order becomes (created_at, id), matching the
index name and its intended purpose. idx_user_id_id and idx_created_at_type are
unaffected.

Note: GORM AutoMigrate does not change the column order of an already-existing
index with the same name, so existing deployments must rebuild the index
manually (see PR description for per-database DDL).

Co-authored-by: wuyupeng <wuyupeng@floatmiracle.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:54:02 +08:00
CaIon 1588027084 feat: add subscription balance redemption toggle (#3071) 2026-05-29 12:54:00 +08:00
zengwei 38bf2d8daa feat(keys/cc-switch-dialog): 修复自定义cc-switch名称失焦后重置问题 (#5170) 2026-05-29 12:18:52 +08:00
同語 e8c836d705 fix(web): improve form validation error focus #5163
Merge pull request #5163 from QuantumNous/fix/form-validation-focus
2026-05-28 23:34:02 +08:00
QuentinHsu e79cee1e9e perf(form): focus first validation error on submit
- scope validation queries with a form root id so feedback stays inside the submitted form.
- scroll to the earliest invalid control or message and move focus without fighting scroll position.
- avoid handling the same failed submit twice to keep retries from jumping unexpectedly.
2026-05-28 15:10:17 +08:00
QuentinHsu 63ead2bf7f chore(repo): ignore playwright mcp artifacts 2026-05-28 15:02:00 +08:00
CaIon 5b86ce0d70 fix: optimize batch update process 2026-05-27 13:23:05 +08:00
115 changed files with 2356 additions and 3146 deletions
+2
View File
@@ -56,6 +56,8 @@
# 对话超时设置
# 所有请求超时时间,单位秒,默认为0,表示不限制
# RELAY_TIMEOUT=0
# Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制
# RELAY_IDLE_CONN_TIMEOUT=90
# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
# STREAMING_TIMEOUT=300
+11 -4
View File
@@ -11,6 +11,8 @@ assignees: ''
- 文档:https://docs.newapi.ai/
- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
- 开启透传后的转发相关反馈不接受 issue;透传模式会直接转发请求,请自行确认上游行为。
- 不接受 coding plan、逆向渠道等技术支持类 issue。
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
**您当前的 newapi 版本**
@@ -20,13 +22,18 @@ assignees: ''
**提交确认**
[//]: # (方框内删除已有的空格,填 x 号)
+ [ ] 我已确认目前没有类似 issue
+ [ ] 我已完整查看文档 https://docs.newapi.ai/项目 README,尤其是常见问题部分
+ [ ] 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
+ [ ] 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
- [ ] **非重复 issue:** 我已搜索现有 [Issues](https://github.com/QuantumNous/new-api/issues?q=is%3Aissue)确认目前没有类似 issue
- [ ] **提交前必读:** 我已完整阅读上方“提交前必读”,并已查看文档 https://docs.newapi.ai/项目 README 且向 AI 提问,确认这不是使用、配置或接入类问题。
- [ ] **模板完整:** 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
- [ ] **维护成本:** 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
**问题描述**
请尽可能说明问题现象、影响范围,以及你判断它是程序问题而不是上游行为或使用问题的依据。
- 转发问题请尽可能说明渠道类型、转换格式、上游原生支持依据和服务端日志。
- 计费问题请尽可能附请求返回的 `usage` 示例。
**复现步骤**
**预期结果**
+11 -4
View File
@@ -11,6 +11,8 @@ assignees: ''
- Docs: https://docs.newapi.ai/
- Usage questions first: https://deepwiki.com/QuantumNous/new-api
- Issues about forwarding behavior after enabling pass-through mode are not accepted; pass-through mode forwards requests directly, so please verify upstream behavior yourself.
- Technical support requests such as coding plans or reverse-engineering channels are not accepted as issues.
- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block.
**Your current newapi version**
@@ -20,13 +22,18 @@ Please fill this in, for example: `v1.0.0`
**Submission Checks**
[//]: # (Remove the space in the box and fill with an x)
+ [ ] I have confirmed there are no similar issues
+ [ ] I have thoroughly read the docs at https://docs.newapi.ai/ and the project README, especially the FAQ section
+ [ ] I have not removed any guidance or section headings from this template and will complete it as requested
+ [ ] I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly
- [ ] **Non-duplicate issue:** I have searched existing [Issues](https://github.com/QuantumNous/new-api/issues?q=is%3Aissue) and confirmed there are no similar issues.
- [ ] **Read this first:** I have fully read the section above, reviewed the docs at https://docs.newapi.ai/ and the project README, and asked AI first, confirming this is not a usage, configuration, or integration question.
- [ ] **Template intact:** I have not removed any guidance or section headings from this template and will complete it as requested.
- [ ] **Maintainer time:** I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly.
**Issue Description**
Describe the symptom, impact scope, and why you believe this is an application issue rather than upstream behavior or a usage question with as much detail as possible.
- For forwarding issues, include the channel type, conversion format, upstream native-support evidence, and server logs when possible.
- For billing issues, include an example of the returned `usage` when possible.
**Steps to Reproduce**
**Expected Result**
+6 -4
View File
@@ -11,6 +11,8 @@ assignees: ''
- 文档:https://docs.newapi.ai/
- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
- 开启透传后的转发相关反馈不接受 issue;透传模式会直接转发请求,请自行确认上游行为。
- 不接受 coding plan、逆向渠道等技术支持类 issue。
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
**您当前的 newapi 版本**
@@ -20,10 +22,10 @@ assignees: ''
**提交确认**
[//]: # (方框内删除已有的空格,填 x 号)
+ [ ] 我已确认目前没有类似 issue
+ [ ] 我已完整查看文档 https://docs.newapi.ai/项目 README,已确定现有版本无法满足需求
+ [ ] 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
+ [ ] 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
- [ ] **非重复 issue:** 我已搜索现有 [Issues](https://github.com/QuantumNous/new-api/issues?q=is%3Aissue)确认目前没有类似 issue
- [ ] **提交前必读:** 我已完整阅读上方“提交前必读”,并已查看文档 https://docs.newapi.ai/项目 README 且向 AI 提问,确认这不是使用、配置或接入类问题,且现有版本无法满足需求
- [ ] **模板完整:** 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
- [ ] **维护成本:** 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
**功能描述**
+6 -4
View File
@@ -11,6 +11,8 @@ assignees: ''
- Docs: https://docs.newapi.ai/
- Usage questions first: https://deepwiki.com/QuantumNous/new-api
- Issues about forwarding behavior after enabling pass-through mode are not accepted; pass-through mode forwards requests directly, so please verify upstream behavior yourself.
- Technical support requests such as coding plans or reverse-engineering channels are not accepted as issues.
- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block.
**Your current newapi version**
@@ -20,10 +22,10 @@ Please fill this in, for example: `v1.0.0`
**Submission Checks**
[//]: # (Remove the space in the box and fill with an x)
+ [ ] I have confirmed there are no similar issues
+ [ ] I have thoroughly read the docs at https://docs.newapi.ai/ and the project README, and confirmed the current version cannot meet my needs
+ [ ] I have not removed any guidance or section headings from this template and will complete it as requested
+ [ ] I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly
- [ ] **Non-duplicate issue:** I have searched existing [Issues](https://github.com/QuantumNous/new-api/issues?q=is%3Aissue) and confirmed there are no similar issues.
- [ ] **Read this first:** I have fully read the section above, reviewed the docs at https://docs.newapi.ai/ and the project README, and asked AI first, confirming this is not a usage, configuration, or integration question, and that the current version cannot meet my needs.
- [ ] **Template intact:** I have not removed any guidance or section headings from this template and will complete it as requested.
- [ ] **Maintainer time:** I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly.
**Feature Description**
+18 -12
View File
@@ -33,16 +33,18 @@ jobs:
env:
CI: ""
run: |
cd web/default
bun install
cd web
bun install --frozen-lockfile
cd default
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Build Frontend (classic)
env:
CI: ""
run: |
cd web/classic
bun install
cd web
bun install --frozen-lockfile
cd classic
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Set up Go
@@ -91,16 +93,18 @@ jobs:
CI: ""
NODE_OPTIONS: "--max-old-space-size=4096"
run: |
cd web/default
bun install
cd web
bun install --frozen-lockfile
cd default
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Build Frontend (classic)
env:
CI: ""
run: |
cd web/classic
bun install
cd web
bun install --frozen-lockfile
cd classic
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Set up Go
@@ -146,16 +150,18 @@ jobs:
env:
CI: ""
run: |
cd web/default
bun install
cd web
bun install --frozen-lockfile
cd default
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Build Frontend (classic)
env:
CI: ""
run: |
cd web/classic
bun install
cd web
bun install --frozen-lockfile
cd classic
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Set up Go
+1
View File
@@ -35,3 +35,4 @@ data/
.test
token_estimator_test.go
skills-lock.json
.playwright-mcp
+18 -16
View File
@@ -1,22 +1,24 @@
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
WORKDIR /build
COPY web/default/package.json .
COPY web/default/bun.lock .
RUN bun install
COPY ./web/default .
COPY ./VERSION .
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
WORKDIR /build/web
COPY web/package.json web/bun.lock ./
COPY web/default/package.json ./default/package.json
COPY web/classic/package.json ./classic/package.json
RUN bun install --frozen-lockfile
COPY ./web/default ./default
COPY ./VERSION /build/VERSION
RUN cd default && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic
WORKDIR /build
COPY web/classic/package.json .
COPY web/classic/bun.lock .
RUN bun install
COPY ./web/classic .
COPY ./VERSION .
RUN VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
WORKDIR /build/web
COPY web/package.json web/bun.lock ./
COPY web/default/package.json ./default/package.json
COPY web/classic/package.json ./classic/package.json
RUN bun install --frozen-lockfile
COPY ./web/classic ./classic
COPY ./VERSION /build/VERSION
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
ENV GO111MODULE=on CGO_ENABLED=0
@@ -32,8 +34,8 @@ ADD go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=builder /build/dist ./web/default/dist
COPY --from=builder-classic /build/dist ./web/classic/dist
COPY --from=builder /build/web/default/dist ./web/default/dist
COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
+1
View File
@@ -316,6 +316,7 @@ docker run --name new-api -d --restart always \
| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - |
| `SQL_DSN` | Database connection string | - |
| `REDIS_CONN_STRING` | Redis connection string | - |
| `RELAY_IDLE_CONN_TIMEOUT` | Idle keep-alive timeout for relay HTTP clients, seconds. Defaults to Go standard library behavior; set `0` to disable | `90` |
| `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` |
+1
View File
@@ -170,6 +170,7 @@ var BatchUpdateInterval int
var RelayTimeout int // unit is second
var RelayIdleConnTimeout int // unit is second
var RelayMaxIdleConns int
var RelayMaxIdleConnsPerHost int
+2
View File
@@ -102,6 +102,7 @@ func InitEnv() {
SyncFrequency = GetEnvOrDefault("SYNC_FREQUENCY", 60)
BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5)
RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0)
RelayIdleConnTimeout = GetEnvOrDefault("RELAY_IDLE_CONN_TIMEOUT", 90)
RelayMaxIdleConns = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS", 500)
RelayMaxIdleConnsPerHost = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS_PER_HOST", 100)
@@ -135,6 +136,7 @@ func initConstantEnv() {
constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 128)
// MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨
constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 128)
constant.AnonymousRequestBodyLimitKB = GetEnvOrDefault("ANONYMOUS_REQUEST_BODY_LIMIT_KB", 512)
// ForceStreamOption 覆盖请求参数,强制返回usage信息
constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true)
constant.CountToken = GetEnvOrDefaultBool("CountToken", true)
+13
View File
@@ -0,0 +1,13 @@
package common
import "github.com/QuantumNous/new-api/constant"
const defaultAnonymousRequestBodyLimitKB = 512
func GetAnonymousRequestBodyLimitBytes() int64 {
limitKB := constant.AnonymousRequestBodyLimitKB
if limitKB < 0 {
limitKB = defaultAnonymousRequestBodyLimitKB
}
return int64(limitKB) << 10
}
+1
View File
@@ -10,6 +10,7 @@ var GetMediaToken bool
var GetMediaTokenNotStream bool
var UpdateTask bool
var MaxRequestBodyMB int
var AnonymousRequestBodyLimitKB int
var AzureDefaultAPIVersion string
var NotifyLimitCount int
var NotificationLimitDurationMinute int
+1 -1
View File
@@ -814,7 +814,7 @@ func buildTestRequest(model string, endpointType string, channel *model.Channel,
testRequest.StreamOptions = &dto.StreamOptions{IncludeUsage: true}
}
if strings.HasPrefix(model, "o") {
if dto.IsOpenAIReasoningOModel(model) {
testRequest.MaxCompletionTokens = lo.ToPtr(uint(16))
} else if strings.Contains(model, "thinking") {
if !strings.Contains(model, "claude") {
+8
View File
@@ -41,6 +41,7 @@ func GetSubscriptionPlans(c *gin.Context) {
}
result := make([]SubscriptionPlanDTO, 0, len(plans))
for _, p := range plans {
p.NormalizeDefaults()
result = append(result, SubscriptionPlanDTO{
Plan: p,
})
@@ -125,6 +126,7 @@ func AdminListSubscriptionPlans(c *gin.Context) {
}
result := make([]SubscriptionPlanDTO, 0, len(plans))
for _, p := range plans {
p.NormalizeDefaults()
result = append(result, SubscriptionPlanDTO{
Plan: p,
})
@@ -163,6 +165,9 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
req.Plan.Currency = "USD"
}
req.Plan.Currency = "USD"
if req.Plan.AllowBalancePay == nil {
req.Plan.AllowBalancePay = common.GetPointer(true)
}
if req.Plan.DurationUnit == "" {
req.Plan.DurationUnit = model.SubscriptionDurationMonth
}
@@ -279,6 +284,9 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
"updated_at": common.GetTimestamp(),
}
if req.Plan.AllowBalancePay != nil {
updateMap["allow_balance_pay"] = *req.Plan.AllowBalancePay
}
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
return err
}
+1
View File
@@ -34,6 +34,7 @@ services:
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
- NODE_NAME=new-api-node-1 # 节点名称,用于审计日志中标识节点身份;多节点/容器部署时建议设置 (Node name used in audit logs; recommended when running multiple instances or in containers)
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions
# - RELAY_IDLE_CONN_TIMEOUT=90 # Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制 (Relay HTTP client idle keep-alive timeout in seconds, defaults to Go standard library; set 0 to disable)
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID)
+12 -2
View File
@@ -213,12 +213,22 @@ func (r *GeneralOpenAIRequest) ToMap() map[string]any {
return result
}
func IsOpenAIReasoningOModel(modelName string) bool {
return strings.HasPrefix(modelName, "o1") ||
strings.HasPrefix(modelName, "o3") ||
strings.HasPrefix(modelName, "o4")
}
func IsOpenAIGPT5Model(modelName string) bool {
return strings.HasPrefix(modelName, "gpt-5")
}
func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
if strings.HasPrefix(r.Model, "o") {
if IsOpenAIReasoningOModel(r.Model) {
if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") {
return "developer"
}
} else if strings.HasPrefix(r.Model, "gpt-5") {
} else if IsOpenAIGPT5Model(r.Model) {
return "developer"
}
return "system"
+24
View File
@@ -71,3 +71,27 @@ func TestOpenAIResponsesRequestPreserveExplicitZeroValues(t *testing.T) {
require.True(t, gjson.GetBytes(encoded, "stream").Exists())
require.True(t, gjson.GetBytes(encoded, "top_p").Exists())
}
func TestGeneralOpenAIRequestGetSystemRoleName(t *testing.T) {
tests := []struct {
name string
model string
want string
}{
{name: "o1 uses developer", model: "o1", want: "developer"},
{name: "o3 family uses developer", model: "o3-mini-high", want: "developer"},
{name: "o4 family uses developer", model: "o4-mini", want: "developer"},
{name: "o1 mini stays system", model: "o1-mini", want: "system"},
{name: "o1 preview stays system", model: "o1-preview", want: "system"},
{name: "gpt 5 uses developer", model: "gpt-5", want: "developer"},
{name: "omni is not o series", model: "omni-moderation-latest", want: "system"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := GeneralOpenAIRequest{Model: tt.model}
require.Equal(t, tt.want, req.GetSystemRoleName())
})
}
}
+32 -5
View File
@@ -1,6 +1,8 @@
FRONTEND_DIR = ./web/default
FRONTEND_CLASSIC_DIR = ./web/classic
BACKEND_DIR = .
DEV_FRONTEND_DEFAULT_PORT ?= 5173
DEV_FRONTEND_CLASSIC_PORT ?= 5174
DEV_COMPOSE_FILE = docker-compose.dev.yml
DEV_POSTGRES_SERVICE = postgres
DEV_BACKEND_SERVICE = new-api
@@ -14,11 +16,13 @@ all: build-all-frontends start-backend
build-frontend:
@echo "Building default frontend..."
@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
@cd ./web && bun install --frozen-lockfile
@cd $(FRONTEND_DIR) && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
build-frontend-classic:
@echo "Building classic frontend..."
@cd $(FRONTEND_CLASSIC_DIR) && bun install && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
@cd ./web && bun install --frozen-lockfile
@cd $(FRONTEND_CLASSIC_DIR) && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
build-all-frontends: build-frontend build-frontend-classic
@@ -35,12 +39,35 @@ dev-api-rebuild:
@docker compose -f $(DEV_COMPOSE_FILE) up -d --build $(DEV_BACKEND_SERVICE)
dev-web:
@echo "Starting frontend dev server..."
@cd $(FRONTEND_DIR) && bun install && bun run dev
@echo "Starting both frontend dev servers..."
@echo "Default frontend: http://localhost:$(DEV_FRONTEND_DEFAULT_PORT)"
@echo "Classic frontend: http://localhost:$(DEV_FRONTEND_CLASSIC_PORT)"
@cd ./web && bun install
@(cd $(FRONTEND_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_DEFAULT_PORT)) & \
default_pid=$$!; \
(cd $(FRONTEND_CLASSIC_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_CLASSIC_PORT)) & \
classic_pid=$$!; \
trap 'kill $$default_pid $$classic_pid 2>/dev/null; wait $$default_pid $$classic_pid 2>/dev/null; exit 130' INT TERM; \
while kill -0 $$default_pid 2>/dev/null && kill -0 $$classic_pid 2>/dev/null; do \
sleep 1; \
done; \
if ! kill -0 $$default_pid 2>/dev/null; then \
wait $$default_pid; \
status=$$?; \
kill $$classic_pid 2>/dev/null; \
wait $$classic_pid 2>/dev/null; \
exit $$status; \
fi; \
wait $$classic_pid; \
status=$$?; \
kill $$default_pid 2>/dev/null; \
wait $$default_pid 2>/dev/null; \
exit $$status
dev-web-classic:
@echo "Starting classic frontend dev server..."
@cd $(FRONTEND_CLASSIC_DIR) && bun install && bun run dev
@cd ./web && bun install
@cd $(FRONTEND_CLASSIC_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_CLASSIC_PORT)
dev: dev-api dev-web
+35 -7
View File
@@ -102,14 +102,10 @@ func Distribute() func(c *gin.Context) {
}
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
affinityUsable := false
preferred, err := model.CacheGetChannel(preferredChannelID)
if err == nil && preferred != nil {
if preferred.Status != common.ChannelStatusEnabled {
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorAffinityChannelDisabled))
return
}
} else if usingGroup == "auto" {
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup)
for _, g := range autoGroups {
@@ -117,6 +113,7 @@ func Distribute() func(c *gin.Context) {
selectGroup = g
common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
channel = preferred
affinityUsable = true
service.MarkChannelAffinityUsed(c, g, preferred.Id)
break
}
@@ -124,9 +121,13 @@ func Distribute() func(c *gin.Context) {
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
channel = preferred
selectGroup = usingGroup
affinityUsable = true
service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
}
}
if !affinityUsable && !service.ShouldKeepChannelAffinityOnChannelDisabled() {
service.ClearCurrentChannelAffinityCache(c)
}
}
if channel == nil {
@@ -298,6 +299,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
} else if c.Request.Method == http.MethodGet {
relayMode = relayconstant.RelayModeVideoFetchByID
shouldSelectChannel = false
modelRequest.Model = getTaskOriginModelName(c)
}
c.Set("relay_mode", relayMode)
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
@@ -312,6 +314,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
} else if c.Request.Method == http.MethodGet {
relayMode = relayconstant.RelayModeVideoFetchByID
shouldSelectChannel = false
modelRequest.Model = getTaskOriginModelName(c)
}
if _, ok := c.Get("relay_mode"); !ok {
c.Set("relay_mode", relayMode)
@@ -396,6 +399,31 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
return &modelRequest, shouldSelectChannel, nil
}
// 修复 #4834: GET /v1/video/generations/:task_id && /v1/video/:task_id 此前不解析 model
// 当 token 启用「可用模型限制」时,下游 modelLimitEnable 校验会因
// modelRequest.Model 为空而误报 "This token has no access to model"。
// 从已存储的任务记录中回填 OriginModelName 即可让校验走在正确的模型上。
func getTaskOriginModelName(c *gin.Context) string {
if !common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) {
return ""
}
taskId := c.Param("task_id")
if taskId == "" {
// jimeng adapter
taskId = c.GetString("task_id")
}
if taskId == "" {
return ""
}
userId := c.GetInt("id")
if task, exist, err := model.GetByTaskId(userId, taskId); err == nil && exist && task != nil {
return task.Properties.OriginModelName
}
return ""
}
func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError {
c.Set("original_model", modelName) // for retry
if channel == nil {
+47
View File
@@ -0,0 +1,47 @@
package middleware
import (
"bytes"
"io"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
func AnonymousRequestBodyLimit() gin.HandlerFunc {
return func(c *gin.Context) {
maxBytes := common.GetAnonymousRequestBodyLimitBytes()
if maxBytes <= 0 || c.Request.Body == nil {
c.Next()
return
}
originalBody := c.Request.Body
limitedBody, err := readAnonymousRequestBody(originalBody, maxBytes)
_ = originalBody.Close()
if err != nil {
if common.IsRequestBodyTooLargeError(err) {
c.AbortWithStatus(http.StatusRequestEntityTooLarge)
return
}
c.AbortWithStatus(http.StatusBadRequest)
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(limitedBody))
c.Request.ContentLength = int64(len(limitedBody))
c.Next()
}
}
func readAnonymousRequestBody(body io.Reader, maxBytes int64) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(body, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, common.ErrRequestBodyTooLarge
}
return data, nil
}
+3 -3
View File
@@ -32,9 +32,9 @@ func applyExplicitLogTextFilter(tx *gorm.DB, column string, value string) (*gorm
}
type Log struct {
Id int `json:"id" gorm:"index:idx_created_at_id,priority:1;index:idx_user_id_id,priority:2"`
Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2"`
UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"`
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type"`
Type int `json:"type" gorm:"index:idx_created_at_type"`
Content string `json:"content"`
Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
@@ -354,7 +354,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
if err != nil {
return nil, 0, err
}
err = tx.Order("logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error
err = tx.Order("logs.created_at desc, logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error
if err != nil {
return nil, 0, err
}
+2
View File
@@ -397,6 +397,7 @@ func ensureSubscriptionPlanTableSQLite() error {
` + "`custom_seconds`" + ` bigint NOT NULL DEFAULT 0,
` + "`enabled`" + ` numeric DEFAULT 1,
` + "`sort_order`" + ` integer DEFAULT 0,
` + "`allow_balance_pay`" + ` numeric DEFAULT 1,
` + "`stripe_price_id`" + ` varchar(128) DEFAULT '',
` + "`creem_product_id`" + ` varchar(128) DEFAULT '',
` + "`waffo_pancake_product_id`" + ` varchar(128) DEFAULT '',
@@ -431,6 +432,7 @@ PRIMARY KEY (` + "`id`" + `)
{Name: "custom_seconds", DDL: "`custom_seconds` bigint NOT NULL DEFAULT 0"},
{Name: "enabled", DDL: "`enabled` numeric DEFAULT 1"},
{Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"},
{Name: "allow_balance_pay", DDL: "`allow_balance_pay` numeric DEFAULT 1"},
{Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"},
{Name: "creem_product_id", DDL: "`creem_product_id` varchar(128) DEFAULT ''"},
{Name: "waffo_pancake_product_id", DDL: "`waffo_pancake_product_id` varchar(128) DEFAULT ''"},
+13
View File
@@ -160,6 +160,8 @@ type SubscriptionPlan struct {
Enabled bool `json:"enabled" gorm:"default:true"`
SortOrder int `json:"sort_order" gorm:"type:int;default:0"`
AllowBalancePay *bool `json:"allow_balance_pay" gorm:"default:true"`
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
WaffoPancakeProductId string `json:"waffo_pancake_product_id" gorm:"type:varchar(128);default:''"`
@@ -193,6 +195,12 @@ func (p *SubscriptionPlan) BeforeUpdate(tx *gorm.DB) error {
return nil
}
func (p *SubscriptionPlan) NormalizeDefaults() {
if p.AllowBalancePay == nil {
p.AllowBalancePay = common.GetPointer(true)
}
}
// Subscription order (payment -> webhook -> create UserSubscription)
type SubscriptionOrder struct {
Id int `json:"id"`
@@ -360,6 +368,7 @@ func getSubscriptionPlanByIdTx(tx *gorm.DB, id int) (*SubscriptionPlan, error) {
key := subscriptionPlanCacheKey(id)
if key != "" {
if cached, found, err := getSubscriptionPlanCache().Get(key); err == nil && found {
cached.NormalizeDefaults()
return &cached, nil
}
}
@@ -371,6 +380,7 @@ func getSubscriptionPlanByIdTx(tx *gorm.DB, id int) (*SubscriptionPlan, error) {
if err := query.Where("id = ?", id).First(&plan).Error; err != nil {
return nil, err
}
plan.NormalizeDefaults()
_ = getSubscriptionPlanCache().SetWithTTL(key, plan, subscriptionPlanCacheTTL())
return &plan, nil
}
@@ -701,6 +711,9 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error {
if plan.PriceAmount < 0 {
return errors.New("套餐价格不能为负数")
}
if plan.AllowBalancePay != nil && !*plan.AllowBalancePay {
return errors.New("该套餐不允许使用余额兑换")
}
requiredQuota, err := calcSubscriptionBalanceQuota(plan.PriceAmount)
if err != nil {
+17
View File
@@ -984,6 +984,23 @@ func updateUserUsedQuotaAndRequestCount(id int, quota int, count int) {
//}
}
func updateUserQuotaUsedQuotaAndRequestCount(id int, quota int, usedQuota int, requestCount int) {
if quota == 0 && usedQuota == 0 && requestCount == 0 {
return
}
err := DB.Model(&User{}).Where("id = ?", id).Updates(
map[string]interface{}{
"quota": gorm.Expr("quota + ?", quota),
"used_quota": gorm.Expr("used_quota + ?", usedQuota),
"request_count": gorm.Expr("request_count + ?", requestCount),
},
).Error
if err != nil {
common.SysLog("failed to batch update user quota, used quota and request count: " + err.Error())
}
}
func updateUserUsedQuota(id int, quota int) {
err := DB.Model(&User{}).Where("id = ?", id).Updates(
map[string]interface{}{
+26 -11
View File
@@ -67,33 +67,48 @@ func batchUpdate() {
}
common.SysLog("batch update started")
stores := make([]map[int]int, BatchUpdateTypeCount)
for i := 0; i < BatchUpdateTypeCount; i++ {
batchUpdateLocks[i].Lock()
store := batchUpdateStores[i]
stores[i] = batchUpdateStores[i]
batchUpdateStores[i] = make(map[int]int)
batchUpdateLocks[i].Unlock()
// TODO: maybe we can combine updates with same key?
}
for i, store := range stores {
if i == BatchUpdateTypeUserQuota || i == BatchUpdateTypeUsedQuota || i == BatchUpdateTypeRequestCount {
continue
}
for key, value := range store {
switch i {
case BatchUpdateTypeUserQuota:
err := increaseUserQuota(key, value)
if err != nil {
common.SysLog("failed to batch update user quota: " + err.Error())
}
case BatchUpdateTypeTokenQuota:
err := increaseTokenQuota(key, value)
if err != nil {
common.SysLog("failed to batch update token quota: " + err.Error())
}
case BatchUpdateTypeUsedQuota:
updateUserUsedQuota(key, value)
case BatchUpdateTypeRequestCount:
updateUserRequestCount(key, value)
case BatchUpdateTypeChannelUsedQuota:
updateChannelUsedQuota(key, value)
}
}
}
userQuotaStore := stores[BatchUpdateTypeUserQuota]
usedQuotaStore := stores[BatchUpdateTypeUsedQuota]
requestCountStore := stores[BatchUpdateTypeRequestCount]
userIDs := make(map[int]struct{}, len(userQuotaStore)+len(usedQuotaStore)+len(requestCountStore))
for key := range userQuotaStore {
userIDs[key] = struct{}{}
}
for key := range usedQuotaStore {
userIDs[key] = struct{}{}
}
for key := range requestCountStore {
userIDs[key] = struct{}{}
}
for key := range userIDs {
updateUserQuotaUsedQuotaAndRequestCount(key, userQuotaStore[key], usedQuotaStore[key], requestCountStore[key])
}
common.SysLog("batch update finished")
}
+6
View File
@@ -19,6 +19,7 @@ var awsModelIDMap = map[string]string{
"claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0",
"claude-opus-4-6": "anthropic.claude-opus-4-6-v1",
"claude-opus-4-7": "anthropic.claude-opus-4-7",
"claude-opus-4-8": "anthropic.claude-opus-4-8",
// Nova models
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
@@ -97,6 +98,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
"ap": true,
"eu": true,
},
"anthropic.claude-opus-4-8": {
"us": true,
"ap": true,
"eu": true,
},
"anthropic.claude-haiku-4-5-20251001-v1:0": {
"us": true,
"ap": true,
+7
View File
@@ -33,6 +33,13 @@ var ModelList = []string{
"claude-opus-4-7-medium",
"claude-opus-4-7-low",
"claude-opus-4-7-thinking",
"claude-opus-4-8",
"claude-opus-4-8-max",
"claude-opus-4-8-xhigh",
"claude-opus-4-8-high",
"claude-opus-4-8-medium",
"claude-opus-4-8-low",
"claude-opus-4-8-thinking",
}
var ChannelName = "claude"
+9 -5
View File
@@ -154,14 +154,17 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
}
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") || strings.HasPrefix(textRequest.Model, "claude-opus-4-7")) {
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") ||
strings.HasPrefix(textRequest.Model, "claude-opus-4-7") ||
strings.HasPrefix(textRequest.Model, "claude-opus-4-8")) {
claudeRequest.Model = baseModel
claudeRequest.Thinking = &dto.Thinking{
Type: "adaptive",
}
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
// Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400
// and defaults display to "omitted"; restore the 4.6 visible summary.
claudeRequest.Thinking.Display = "summarized"
claudeRequest.Temperature = nil
@@ -175,8 +178,9 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
strings.HasSuffix(textRequest.Model, "-thinking") {
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") {
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") ||
strings.HasPrefix(trimmedModel, "claude-opus-4-8") {
// Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort.
claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
claudeRequest.Temperature = nil
+56
View File
@@ -9,6 +9,10 @@ import (
"github.com/stretchr/testify/require"
)
func commonPointer[T any](value T) *T {
return &value
}
func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) {
claudeInfo := &ClaudeResponseInfo{
Usage: &dto.Usage{},
@@ -310,6 +314,58 @@ func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T)
require.Equal(t, "see attachment", *content[0].Text)
}
func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48HighUsesAdaptiveThinking(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-opus-4-8-high",
Temperature: commonPointer(0.7),
TopP: commonPointer(0.9),
TopK: commonPointer(40),
Messages: []dto.Message{
{
Role: "user",
Content: "hello",
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
require.NotNil(t, claudeRequest.Thinking)
require.Equal(t, "adaptive", claudeRequest.Thinking.Type)
require.Equal(t, "summarized", claudeRequest.Thinking.Display)
require.JSONEq(t, `{"effort":"high"}`, string(claudeRequest.OutputConfig))
require.Nil(t, claudeRequest.Temperature)
require.Nil(t, claudeRequest.TopP)
require.Nil(t, claudeRequest.TopK)
}
func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48ThinkingUsesAdaptiveHighEffort(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-opus-4-8-thinking",
Temperature: commonPointer(0.7),
TopP: commonPointer(0.9),
TopK: commonPointer(40),
Messages: []dto.Message{
{
Role: "user",
Content: "hello",
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
require.NotNil(t, claudeRequest.Thinking)
require.Equal(t, "adaptive", claudeRequest.Thinking.Type)
require.Equal(t, "summarized", claudeRequest.Thinking.Display)
require.JSONEq(t, `{"effort":"high"}`, string(claudeRequest.OutputConfig))
require.Nil(t, claudeRequest.Temperature)
require.Nil(t, claudeRequest.TopP)
require.Nil(t, claudeRequest.TopK)
}
func TestRequestOpenAI2ClaudeMessage_SupportsPDFFileContent(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
+1 -1
View File
@@ -30,7 +30,7 @@ func convertCf2CompletionsRequest(textRequest dto.GeneralOpenAIRequest) *CfReque
}
func cfStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
scanner := bufio.NewScanner(resp.Body)
scanner := helper.NewStreamScanner(resp.Body)
scanner.Split(bufio.ScanLines)
helper.SetEventStreamHeaders(c)
+4 -2
View File
@@ -1,7 +1,6 @@
package cohere
import (
"bufio"
"encoding/json"
"io"
"net/http"
@@ -86,7 +85,7 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
createdTime := common.GetTimestamp()
usage := &dto.Usage{}
responseText := ""
scanner := bufio.NewScanner(resp.Body)
scanner := helper.NewStreamScanner(resp.Body)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
@@ -106,6 +105,9 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
data := scanner.Text()
dataChan <- data
}
if err := scanner.Err(); err != nil {
common.SysLog("error reading stream: " + err.Error())
}
stopChan <- true
}()
helper.SetEventStreamHeaders(c)
+1 -1
View File
@@ -98,7 +98,7 @@ func cozeChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Res
}
func cozeChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
scanner := bufio.NewScanner(resp.Body)
scanner := helper.NewStreamScanner(resp.Body)
scanner.Split(bufio.ScanLines)
helper.SetEventStreamHeaders(c)
id := helper.GetResponseID(c)
+8 -3
View File
@@ -159,9 +159,14 @@ func requestOpenAI2Dify(c *gin.Context, info *relaycommon.RelayInfo, request dto
media := mediaContent.GetImageMedia()
var file *DifyFile
if media.IsRemoteImage() {
file.Type = media.MimeType
file.TransferMode = "remote_url"
file.URL = media.Url
// 修复 #2083: 远程图片分支此前未初始化 file,
// 导致 file.Type = ... 触发 nil pointer dereference
// 而 panic500: "invalid memory address or nil pointer dereference")。
file = &DifyFile{
Type: media.MimeType,
TransferMode: "remote_url",
URL: media.Url,
}
} else {
file = uploadDifyFile(c, info, difyReq.User, mediaContent)
}
+2 -2
View File
@@ -1,7 +1,6 @@
package ollama
import (
"bufio"
"encoding/json"
"fmt"
"io"
@@ -12,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
@@ -397,7 +397,7 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f
}
// 读取流式响应
scanner := bufio.NewScanner(response.Body)
scanner := helper.NewStreamScanner(response.Body)
successful := false
for scanner.Scan() {
line := scanner.Text()
+1 -2
View File
@@ -1,7 +1,6 @@
package ollama
import (
"bufio"
"encoding/json"
"fmt"
"io"
@@ -70,7 +69,7 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
defer service.CloseResponseBodyGracefully(resp)
helper.SetEventStreamHeaders(c)
scanner := bufio.NewScanner(resp.Body)
scanner := helper.NewStreamScanner(resp.Body)
usage := &dto.Usage{}
var model = info.UpstreamModelName
var responseId = common.GetUUID()
+5 -3
View File
@@ -310,18 +310,20 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
}
if strings.HasPrefix(info.UpstreamModelName, "o") || strings.HasPrefix(info.UpstreamModelName, "gpt-5") {
isOModel := dto.IsOpenAIReasoningOModel(info.UpstreamModelName)
isGPT5Model := dto.IsOpenAIGPT5Model(info.UpstreamModelName)
if isOModel || isGPT5Model {
if lo.FromPtrOr(request.MaxCompletionTokens, uint(0)) == 0 && lo.FromPtrOr(request.MaxTokens, uint(0)) != 0 {
request.MaxCompletionTokens = request.MaxTokens
request.MaxTokens = nil
}
if strings.HasPrefix(info.UpstreamModelName, "o") {
if isOModel {
request.Temperature = nil
}
// gpt-5系列模型适配 归零不再支持的参数
if strings.HasPrefix(info.UpstreamModelName, "gpt-5") {
if isGPT5Model {
request.Temperature = nil
request.TopP = nil
request.LogProbs = nil
+1 -1
View File
@@ -92,7 +92,7 @@ func streamResponseTencent2OpenAI(TencentResponse *TencentChatResponse) *dto.Cha
func tencentStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
var responseText string
scanner := bufio.NewScanner(resp.Body)
scanner := helper.NewStreamScanner(resp.Body)
scanner.Split(bufio.ScanLines)
helper.SetEventStreamHeaders(c)
+1
View File
@@ -45,6 +45,7 @@ var claudeModelMap = map[string]string{
"claude-opus-4-5-20251101": "claude-opus-4-5@20251101",
"claude-opus-4-6": "claude-opus-4-6",
"claude-opus-4-7": "claude-opus-4-7",
"claude-opus-4-8": "claude-opus-4-8",
}
const anthropicVersion = "vertex-2023-10-16"
+4 -1
View File
@@ -157,7 +157,7 @@ func streamMetaResponseZhipu2OpenAI(zhipuResponse *ZhipuStreamMetaResponse) (*dt
func zhipuStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
var usage *dto.Usage
scanner := bufio.NewScanner(resp.Body)
scanner := helper.NewStreamScanner(resp.Body)
scanner.Split(bufio.ScanLines)
dataChan := make(chan string)
metaChan := make(chan string)
@@ -180,6 +180,9 @@ func zhipuStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
}
}
}
if err := scanner.Err(); err != nil {
common.SysLog("error reading stream: " + err.Error())
}
stopChan <- true
}()
helper.SetEventStreamHeaders(c)
+10 -5
View File
@@ -53,14 +53,17 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" &&
(strings.HasPrefix(request.Model, "claude-opus-4-6") || strings.HasPrefix(request.Model, "claude-opus-4-7")) {
(strings.HasPrefix(request.Model, "claude-opus-4-6") ||
strings.HasPrefix(request.Model, "claude-opus-4-7") ||
strings.HasPrefix(request.Model, "claude-opus-4-8")) {
request.Model = baseModel
request.Thinking = &dto.Thinking{
Type: "adaptive",
}
request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
if strings.HasPrefix(request.Model, "claude-opus-4-7") {
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
if strings.HasPrefix(request.Model, "claude-opus-4-7") ||
strings.HasPrefix(request.Model, "claude-opus-4-8") {
// Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400
// and defaults display to "omitted"; restore the 4.6 visible summary.
request.Thinking.Display = "summarized"
request.Temperature = nil
@@ -74,8 +77,9 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
strings.HasSuffix(request.Model, "-thinking") {
if request.Thinking == nil {
baseModel := strings.TrimSuffix(request.Model, "-thinking")
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
// Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort.
request.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
request.OutputConfig = json.RawMessage(`{"effort":"high"}`)
request.Temperature = nil
@@ -151,6 +155,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if err != nil {
return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
info.UpstreamRequestBodySize = storage.Size()
requestBody = common.ReaderOnly(storage)
} else {
convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, request)
+7 -2
View File
@@ -34,6 +34,12 @@ func getScannerBufferSize() int {
return DefaultMaxScannerBufferSize
}
func NewStreamScanner(reader io.Reader) *bufio.Scanner {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, InitialScannerBufferSize), getScannerBufferSize())
return scanner
}
func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, dataHandler func(data string, sr *StreamResult)) {
if resp == nil || dataHandler == nil {
@@ -54,7 +60,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
var (
stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞
scanner = bufio.NewScanner(resp.Body)
scanner = NewStreamScanner(resp.Body)
ticker = time.NewTicker(streamingTimeout)
pingTicker *time.Ticker
writeMutex sync.Mutex // Mutex to protect concurrent writes
@@ -104,7 +110,6 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
close(stopChan)
}()
scanner.Buffer(make([]byte, InitialScannerBufferSize), getScannerBufferSize())
scanner.Split(bufio.ScanLines)
SetEventStreamHeaders(c)
+17
View File
@@ -1,6 +1,7 @@
package helper
import (
"bufio"
"fmt"
"io"
"net/http"
@@ -81,6 +82,22 @@ func TestStreamScannerHandler_NilInputs(t *testing.T) {
StreamScannerHandler(c, &http.Response{Body: io.NopCloser(strings.NewReader(""))}, info, nil)
}
func TestNewStreamScanner_AllowsLargeStreamLine(t *testing.T) {
oldBufferMB := constant.StreamScannerMaxBufferMB
constant.StreamScannerMaxBufferMB = 1
t.Cleanup(func() {
constant.StreamScannerMaxBufferMB = oldBufferMB
})
payload := strings.Repeat("x", 128<<10)
scanner := NewStreamScanner(strings.NewReader("data: " + payload + "\n"))
scanner.Split(bufio.ScanLines)
require.True(t, scanner.Scan())
assert.Equal(t, "data: "+payload, scanner.Text())
require.NoError(t, scanner.Err())
}
func TestStreamScannerHandler_EmptyBody(t *testing.T) {
t.Parallel()
+17 -16
View File
@@ -17,9 +17,10 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
apiRouter.Use(middleware.BodyStorageCleanup()) // 清理请求体存储
apiRouter.Use(middleware.GlobalAPIRateLimit())
anonymousRequestBodyLimit := middleware.AnonymousRequestBodyLimit()
{
apiRouter.GET("/setup", controller.GetSetup)
apiRouter.POST("/setup", controller.PostSetup)
apiRouter.POST("/setup", anonymousRequestBodyLimit, controller.PostSetup)
apiRouter.GET("/status", controller.GetStatus)
apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus)
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
@@ -40,39 +41,39 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/rankings", middleware.HeaderNavModuleAuth("rankings"), controller.GetRankings)
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.ResetPassword)
// OAuth routes - specific routes must come before :provider wildcard
apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode)
apiRouter.POST("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind)
apiRouter.POST("/oauth/email/bind", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.EmailBind)
// Non-standard OAuth (WeChat, Telegram) - keep original routes
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
apiRouter.POST("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind)
apiRouter.POST("/oauth/wechat/bind", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.WeChatBind)
apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin)
apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind)
// Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route
apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), controller.HandleOAuth)
apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
apiRouter.POST("/creem/webhook", controller.CreemWebhook)
apiRouter.POST("/waffo/webhook", controller.WaffoWebhook)
apiRouter.POST("/stripe/webhook", anonymousRequestBodyLimit, controller.StripeWebhook)
apiRouter.POST("/creem/webhook", anonymousRequestBodyLimit, controller.CreemWebhook)
apiRouter.POST("/waffo/webhook", anonymousRequestBodyLimit, controller.WaffoWebhook)
// :env separates test vs prod URLs so the operator can register each
// in Pancake's matching webhook slot; handler enforces env match.
apiRouter.POST("/waffo-pancake/webhook/:env", controller.WaffoPancakeWebhook)
apiRouter.POST("/waffo-pancake/webhook/:env", anonymousRequestBodyLimit, controller.WaffoPancakeWebhook)
// Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
userRoute := apiRouter.Group("/user")
{
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login)
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin)
userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), controller.PasskeyLoginBegin)
userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), controller.PasskeyLoginFinish)
userRoute.POST("/register", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Register)
userRoute.POST("/login", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Login)
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.Verify2FALogin)
userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.PasskeyLoginBegin)
userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.PasskeyLoginFinish)
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
userRoute.GET("/logout", controller.Logout)
userRoute.POST("/epay/notify", controller.EpayNotify)
userRoute.POST("/epay/notify", anonymousRequestBodyLimit, controller.EpayNotify)
userRoute.GET("/epay/notify", controller.EpayNotify)
userRoute.GET("/groups", controller.GetUserGroups)
@@ -176,10 +177,10 @@ func SetApiRouter(router *gin.Engine) {
}
// Subscription payment callbacks (no auth)
apiRouter.POST("/subscription/epay/notify", controller.SubscriptionEpayNotify)
apiRouter.POST("/subscription/epay/notify", anonymousRequestBodyLimit, controller.SubscriptionEpayNotify)
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
apiRouter.GET("/subscription/epay/return", controller.SubscriptionEpayReturn)
apiRouter.POST("/subscription/epay/return", controller.SubscriptionEpayReturn)
apiRouter.POST("/subscription/epay/return", anonymousRequestBodyLimit, controller.SubscriptionEpayReturn)
optionRoute := apiRouter.Group("/option")
optionRoute.Use(middleware.RootAuth())
{
+32
View File
@@ -641,6 +641,38 @@ func ShouldSkipRetryAfterChannelAffinityFailure(c *gin.Context) bool {
return meta.SkipRetry
}
func ClearCurrentChannelAffinityCache(c *gin.Context) bool {
if c == nil {
return false
}
cacheKey, _, ok := getChannelAffinityContext(c)
if !ok || cacheKey == "" {
return false
}
cache := getChannelAffinityCache()
deleted, err := cache.DeleteMany([]string{cacheKey})
if err != nil {
common.SysError(fmt.Sprintf("channel affinity cache delete current failed: err=%v", err))
return false
}
c.Set(ginKeyChannelAffinitySkipRetry, false)
for _, ok := range deleted {
if ok {
return true
}
}
return false
}
func ShouldKeepChannelAffinityOnChannelDisabled() bool {
setting := operation_setting.GetChannelAffinitySetting()
if setting == nil {
return false
}
return setting.KeepOnChannelDisabled
}
func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int) {
if c == nil || channelID <= 0 {
return
+27
View File
@@ -236,6 +236,33 @@ func TestGetPreferredChannelByAffinity_RequestHeaderKeySource(t *testing.T) {
require.Equal(t, buildChannelAffinityKeyHint(affinityValue), meta.KeyHint)
}
func TestClearCurrentChannelAffinityCache(t *testing.T) {
gin.SetMode(gin.TestMode)
cacheKeySuffix := fmt.Sprintf("codex cli trace:default:clear-current-%d", time.Now().UnixNano())
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
t.Cleanup(func() {
_, _ = cache.DeleteMany([]string{cacheKeySuffix})
})
ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
CacheKey: cacheKeyFull,
TTLSeconds: 60,
RuleName: "codex cli trace",
SkipRetry: true,
})
require.True(t, ShouldSkipRetryAfterChannelAffinityFailure(ctx))
deleted := ClearCurrentChannelAffinityCache(ctx)
require.True(t, deleted)
_, found, err := cache.Get(cacheKeySuffix)
require.NoError(t, err)
require.False(t, found)
require.False(t, ShouldSkipRetryAfterChannelAffinityFailure(ctx))
}
func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
gin.SetMode(gin.TestMode)
+3
View File
@@ -37,6 +37,7 @@ func InitHttpClient() {
transport := &http.Transport{
MaxIdleConns: common.RelayMaxIdleConns,
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second,
ForceAttemptHTTP2: true,
Proxy: http.ProxyFromEnvironment, // Support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars
}
@@ -108,6 +109,7 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
transport := &http.Transport{
MaxIdleConns: common.RelayMaxIdleConns,
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second,
ForceAttemptHTTP2: true,
Proxy: http.ProxyURL(parsedURL),
}
@@ -147,6 +149,7 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
transport := &http.Transport{
MaxIdleConns: common.RelayMaxIdleConns,
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second,
ForceAttemptHTTP2: true,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.Dial(network, addr)
@@ -28,11 +28,12 @@ type ChannelAffinityRule struct {
}
type ChannelAffinitySetting struct {
Enabled bool `json:"enabled"`
SwitchOnSuccess bool `json:"switch_on_success"`
MaxEntries int `json:"max_entries"`
DefaultTTLSeconds int `json:"default_ttl_seconds"`
Rules []ChannelAffinityRule `json:"rules"`
Enabled bool `json:"enabled"`
SwitchOnSuccess bool `json:"switch_on_success"`
KeepOnChannelDisabled bool `json:"keep_on_channel_disabled"`
MaxEntries int `json:"max_entries"`
DefaultTTLSeconds int `json:"default_ttl_seconds"`
Rules []ChannelAffinityRule `json:"rules"`
}
var codexCliPassThroughHeaders = []string{
@@ -74,10 +75,11 @@ func buildPassHeaderTemplate(headers []string) map[string]interface{} {
}
var channelAffinitySetting = ChannelAffinitySetting{
Enabled: true,
SwitchOnSuccess: true,
MaxEntries: 100_000,
DefaultTTLSeconds: 3600,
Enabled: true,
SwitchOnSuccess: true,
KeepOnChannelDisabled: false,
MaxEntries: 100_000,
DefaultTTLSeconds: 3600,
Rules: []ChannelAffinityRule{
{
Name: "codex cli trace",
+14
View File
@@ -71,6 +71,13 @@ var defaultCacheRatio = map[string]float64{
"claude-opus-4-7-high": 0.1,
"claude-opus-4-7-medium": 0.1,
"claude-opus-4-7-low": 0.1,
"claude-opus-4-8": 0.1,
"claude-opus-4-8-thinking": 0.1,
"claude-opus-4-8-max": 0.1,
"claude-opus-4-8-xhigh": 0.1,
"claude-opus-4-8-high": 0.1,
"claude-opus-4-8-medium": 0.1,
"claude-opus-4-8-low": 0.1,
}
var defaultCreateCacheRatio = map[string]float64{
@@ -106,6 +113,13 @@ var defaultCreateCacheRatio = map[string]float64{
"claude-opus-4-7-high": 1.25,
"claude-opus-4-7-medium": 1.25,
"claude-opus-4-7-low": 1.25,
"claude-opus-4-8": 1.25,
"claude-opus-4-8-thinking": 1.25,
"claude-opus-4-8-max": 1.25,
"claude-opus-4-8-xhigh": 1.25,
"claude-opus-4-8-high": 1.25,
"claude-opus-4-8-medium": 1.25,
"claude-opus-4-8-low": 1.25,
}
//var defaultCreateCacheRatio = map[string]float64{}
+6
View File
@@ -152,6 +152,12 @@ var defaultModelRatio = map[string]float64{
"claude-opus-4-7-high": 2.5,
"claude-opus-4-7-medium": 2.5,
"claude-opus-4-7-low": 2.5,
"claude-opus-4-8": 2.5,
"claude-opus-4-8-max": 2.5,
"claude-opus-4-8-xhigh": 2.5,
"claude-opus-4-8-high": 2.5,
"claude-opus-4-8-medium": 2.5,
"claude-opus-4-8-low": 2.5,
"claude-3-opus-20240229": 7.5, // $15 / 1M tokens
"claude-opus-4-20250514": 7.5,
"claude-opus-4-1-20250805": 7.5,
+1136 -371
View File
File diff suppressed because it is too large Load Diff
-2379
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -24,6 +24,5 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
+21 -20
View File
@@ -4,30 +4,32 @@
"private": true,
"type": "module",
"dependencies": {
"@douyinfe/semi-illustrations": "^2.69.1",
"@douyinfe/semi-icons": "^2.63.1",
"@douyinfe/semi-ui": "^2.69.1",
"@lobehub/icons": "^2.0.0",
"@lobehub/icons": "catalog:",
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"axios": "1.15.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"axios": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"history": "^5.3.0",
"highlight.js": "^11.11.1",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^7.2.0",
"katex": "^0.16.22",
"lucide-react": "^0.511.0",
"marked": "^4.1.1",
"mermaid": "^11.6.0",
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"qrcode.react": "catalog:",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-dropzone": "^14.2.3",
"react-fireworks": "^1.0.4",
"react-i18next": "^13.0.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-icons": "catalog:",
"react-markdown": "catalog:",
"react-router-dom": "^6.3.0",
"react-telegram-login": "^1.1.2",
"react-toastify": "^9.0.8",
@@ -35,20 +37,20 @@
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-gfm": "catalog:",
"remark-math": "^6.0.0",
"sse.js": "^2.6.0",
"sse.js": "catalog:",
"unist-util-visit": "^5.0.0",
"use-debounce": "^10.0.4"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"dev": "rsbuild dev",
"build": "rsbuild build",
"lint": "prettier . --check",
"lint:fix": "prettier . --write",
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
"eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache",
"preview": "vite preview",
"preview": "rsbuild preview",
"i18n:extract": "bunx i18next-cli extract",
"i18n:status": "bunx i18next-cli status",
"i18n:sync": "bunx i18next-cli sync",
@@ -73,20 +75,19 @@
]
},
"devDependencies": {
"@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
"@rsbuild/core": "^2.0.7",
"@rsbuild/plugin-react": "^2.0.0",
"@so1ve/prettier-config": "^3.1.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"code-inspector-plugin": "^1.3.3",
"eslint": "8.57.0",
"eslint-plugin-header": "^3.1.1",
"eslint-plugin-react-hooks": "^5.2.0",
"i18next-cli": "^1.10.3",
"postcss": "^8.5.3",
"prettier": "^3.0.0",
"prop-types": "^15.8.1",
"prettier": "catalog:",
"tailwindcss": "^3",
"typescript": "4.4.2",
"vite": "^5.2.0"
"typescript": "4.4.2"
},
"prettier": {
"singleQuote": true,
+106
View File
@@ -0,0 +1,106 @@
import path from 'path'
import { createRequire } from 'module'
import { fileURLToPath } from 'url'
import { defineConfig, loadEnv } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const semiUiDir = path.resolve(
path.dirname(require.resolve('@douyinfe/semi-ui')),
'../..',
)
export default defineConfig(({ envMode }) => {
const env = loadEnv({ mode: envMode, prefixes: ['VITE_'] })
const clientServerUrl =
process.env.VITE_REACT_APP_SERVER_URL ||
env.rawPublicVars.VITE_REACT_APP_SERVER_URL ||
''
const proxyServerUrl =
clientServerUrl ||
'http://localhost:3000'
const isProd = envMode === 'production'
const devProxy = Object.fromEntries(
(['/api', '/mj', '/pg'] as const).map((key) => [
key,
{ target: proxyServerUrl, changeOrigin: true },
]),
) as Record<string, { target: string; changeOrigin: boolean }>
return {
plugins: [pluginReact()],
source: {
entry: {
index: './src/index.jsx',
},
define: {
'import.meta.env.VITE_REACT_APP_SERVER_URL': JSON.stringify(
clientServerUrl,
),
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@douyinfe/semi-ui/dist/css/semi.css': path.resolve(
semiUiDir,
'dist/css/semi.css',
),
},
},
html: {
template: './index.html',
},
server: {
host: '0.0.0.0',
strictPort: true,
proxy: devProxy,
},
output: {
minify: isProd,
target: 'web',
distPath: {
root: 'dist',
},
},
performance: {
removeConsole: isProd ? ['log'] : false,
buildCache: {
cacheDigest: [process.env.VITE_REACT_APP_VERSION],
},
},
tools: {
rspack: {
module: {
rules: [
{
test: /src[\\/].*\.js$/,
type: 'javascript/auto',
use: [
{
loader: 'builtin:swc-loader',
options: {
jsc: {
parser: {
syntax: 'ecmascript',
jsx: true,
},
transform: {
react: {
runtime: 'automatic',
development: !isProd,
refresh: !isProd,
},
},
},
},
},
],
},
],
},
},
},
}
})
+1 -1
View File
@@ -947,7 +947,7 @@ const LoginForm = () => {
};
return (
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div
className='blur-ball blur-ball-indigo'
+1 -1
View File
@@ -104,7 +104,7 @@ const PasswordResetConfirm = () => {
}
return (
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div
className='blur-ball blur-ball-indigo'
+1 -1
View File
@@ -104,7 +104,7 @@ const PasswordResetForm = () => {
}
return (
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div
className='blur-ball blur-ball-indigo'
+1 -1
View File
@@ -770,7 +770,7 @@ const RegisterForm = () => {
};
return (
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div
className='blur-ball blur-ball-indigo'
@@ -141,7 +141,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
//
if (loading) {
return (
<div className='flex justify-center items-center min-h-screen'>
<div className='classic-page-fill flex justify-center items-center'>
<Spin size='large' />
</div>
);
@@ -150,7 +150,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
//
if (!content || content.trim() === '') {
return (
<div className='flex justify-center items-center min-h-screen bg-gray-50'>
<div className='classic-page-fill flex justify-center items-center bg-gray-50'>
<Empty
title={t('管理员未设置' + title + '内容')}
image={
@@ -168,7 +168,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// URL
if (isUrl(content)) {
return (
<div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'>
<div className='classic-page-fill flex justify-center items-center bg-gray-50 p-4'>
<Card className='max-w-md w-full'>
<div className='text-center'>
<Title heading={4} className='mb-4'>
@@ -196,7 +196,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// HTML
if (isHtmlContent(content)) {
return (
<div className='min-h-screen bg-gray-50'>
<div className='classic-page-fill bg-gray-50'>
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
<div className='bg-white rounded-lg shadow-sm p-8'>
<Title heading={2} className='text-center mb-8'>
@@ -214,7 +214,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// 使 Markdown
return (
<div className='min-h-screen bg-gray-50'>
<div className='classic-page-fill bg-gray-50'>
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
<div className='bg-white rounded-lg shadow-sm p-8'>
<Title heading={2} className='text-center mb-8'>
+10 -5
View File
@@ -71,6 +71,7 @@ const PageLayout = () => {
const isConsoleRoute = location.pathname.startsWith('/console');
const showSider = isConsoleRoute && (!isMobile || drawerOpen);
const isFixedLayout = isConsoleRoute || location.pathname === '/pricing';
useEffect(() => {
if (isMobile && drawerOpen && collapsed) {
@@ -146,11 +147,11 @@ const PageLayout = () => {
return (
<Layout
className='app-layout'
className={`app-layout${isFixedLayout ? ' app-layout-fixed' : ''}`}
style={{
display: 'flex',
flexDirection: 'column',
overflow: isMobile ? 'visible' : 'hidden',
overflow: isFixedLayout && !isMobile ? 'hidden' : 'visible',
}}
>
<Header
@@ -171,9 +172,10 @@ const PageLayout = () => {
</Header>
<Layout
style={{
overflow: isMobile ? 'visible' : 'auto',
overflow: isFixedLayout && !isMobile ? 'auto' : 'visible',
display: 'flex',
flexDirection: 'column',
flex: '1 1 auto',
}}
>
{showSider && (
@@ -206,15 +208,18 @@ const PageLayout = () => {
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
<Content
className={isFixedLayout ? undefined : 'public-page-content'}
style={{
flex: '1 0 auto',
overflowY: isMobile ? 'visible' : 'hidden',
flex: isFixedLayout ? '1 0 auto' : '1 1 auto',
overflowY: isFixedLayout && !isMobile ? 'hidden' : 'visible',
WebkitOverflowScrolling: 'touch',
padding: shouldInnerPadding ? (isMobile ? '5px' : '24px') : '0',
position: 'relative',
minHeight: 0,
}}
>
<ErrorBoundary>
+43 -9
View File
@@ -17,12 +17,46 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export * from './channel.constants';
export * from './user.constants';
export * from './toast.constants';
export * from './common.constant';
export * from './dashboard.constants';
export * from './playground.constants';
export * from './redemption.constants';
export * from './channel-affinity-template.constants';
export * from './billing.constants';
export {
CHANNEL_OPTIONS,
MODEL_FETCHABLE_CHANNEL_TYPES,
MODEL_TABLE_PAGE_SIZE,
} from './channel.constants';
export { userConstants } from './user.constants';
export { toastConstants } from './toast.constants';
export {
ITEMS_PER_PAGE,
DEFAULT_ENDPOINT,
TABLE_COMPACT_MODES_KEY,
API_ENDPOINTS,
TASK_ACTION_GENERATE,
TASK_ACTION_TEXT_GENERATE,
TASK_ACTION_FIRST_TAIL_GENERATE,
TASK_ACTION_REFERENCE_GENERATE,
TASK_ACTION_REMIX_GENERATE,
} from './common.constant';
export {
REDEMPTION_STATUS,
REDEMPTION_STATUS_MAP,
REDEMPTION_ACTIONS,
} from './redemption.constants';
export {
CODEX_CLI_HEADER_PASSTHROUGH_HEADERS,
CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS,
CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
CHANNEL_AFFINITY_RULE_TEMPLATES,
cloneChannelAffinityTemplate,
} from './channel-affinity-template.constants';
export {
BILLING_VARS,
BILLING_VAR_KEYS,
BILLING_PRICING_VARS,
BILLING_EXTRA_VARS,
BILLING_VAR_KEY_TO_FIELD,
BILLING_VAR_FIELD_TO_LABEL,
BILLING_VAR_FIELD_TO_SHORT_LABEL,
BILLING_CACHE_VAR_MAP,
BILLING_VAR_REGEX,
BILLING_CONDITION_VARS,
} from './billing.constants';
+8 -22
View File
@@ -94,7 +94,6 @@ import {
SiGitlab,
SiGoogle,
SiKeycloak,
SiLinkedin,
SiNextcloud,
SiNotion,
SiOkta,
@@ -106,6 +105,7 @@ import {
SiWechat,
SiX,
} from 'react-icons/si';
import { FaLinkedin } from 'react-icons/fa';
// Lucide
export function getLucideIcon(key, selected = false) {
@@ -509,7 +509,7 @@ const oauthProviderIconMap = {
google: SiGoogle,
discord: SiDiscord,
facebook: SiFacebook,
linkedin: SiLinkedin,
linkedin: FaLinkedin,
x: SiX,
twitter: SiX,
slack: SiSlack,
@@ -1068,31 +1068,17 @@ export function getQuotaWithUnit(quota, digits = 6) {
return (quota / quotaPerUnit).toFixed(digits);
}
// amount
export function renderQuotaWithAmount(amount) {
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
if (quotaDisplayType === 'TOKENS') {
const { symbol, rate, type } = getCurrencyConfig();
if (type === 'TOKENS') {
return renderNumber(renderUnitWithQuota(amount));
}
const numericAmount = Number(amount);
const formattedAmount = Number.isFinite(numericAmount)
? numericAmount.toFixed(2)
: amount;
if (quotaDisplayType === 'CNY') {
return '¥' + formattedAmount;
} else if (quotaDisplayType === 'CUSTOM') {
const statusStr = localStorage.getItem('status');
let symbol = '¤';
try {
if (statusStr) {
const s = JSON.parse(statusStr);
symbol = s?.custom_currency_symbol || symbol;
}
} catch (e) {}
return symbol + formattedAmount;
if (!Number.isFinite(numericAmount)) {
return symbol + amount;
}
return '$' + formattedAmount;
return symbol + (numericAmount * rate).toFixed(2);
}
/**
+1 -1
View File
@@ -123,7 +123,7 @@ export function showError(error) {
console.error(error);
if (error.message) {
if (error.name === 'AxiosError') {
switch (error.response.status) {
switch (error.response?.status) {
case 401:
//
localStorage.removeItem('user');
+2
View File
@@ -1197,6 +1197,7 @@
"套餐的基本信息和定价": "Basic plan info and pricing",
"如:大带宽批量分析图片推荐": "e.g. Large bandwidth batch analysis of image recommendations",
"如:香港线路": "e.g. Hong Kong line",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. When disabled, the entry will be deleted and another channel will be selected.",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "If the affinity channel fails, after a successful retry on another channel, the affinity will be updated to the successful channel.",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "If you are connecting to upstream One API or New API forwarding projects, please use OpenAI type. Do not use this type unless you know what you are doing.",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "If the user request contains a system prompt, this setting will be appended to the user's system prompt",
@@ -1579,6 +1580,7 @@
"成功": "Success",
"成功兑换额度:": "Successful redemption amount:",
"成功后切换亲和": "Switch Affinity on Success",
"渠道禁用后保留亲和": "Keep Affinity When Channel Is Disabled",
"成功时自动启用通道": "Enable channel when successful",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "I have understood that disabling two-factor authentication will permanently delete all related settings and backup codes, this operation cannot be undone",
"我已阅读并同意": "I have read and agree to",
+2
View File
@@ -1193,6 +1193,7 @@
"套餐的基本信息和定价": "Informations de base et tarification du plan",
"如:大带宽批量分析图片推荐": "par exemple, Recommandations d'analyse d'images par lots à large bande passante",
"如:香港线路": "par exemple, Ligne de Hong Kong",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "Lorsque cette option est activée, conserver l'entrée d'affinité même si le canal d'affinité est désactivé ou n'est plus utilisable pour le groupe/modèle actuel. Lorsqu'elle est désactivée, l'entrée sera supprimée et un autre canal sera sélectionné.",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "Si le canal d'affinité échoue, après une nouvelle tentative réussie sur un autre canal, l'affinité sera mise à jour vers le canal réussi.",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "Si vous vous connectez à des projets de redirection One API ou New API en amont, veuillez utiliser le type OpenAI. N'utilisez pas ce type, sauf si vous savez ce que vous faites.",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "Si la requête de l'utilisateur contient un prompt système, utilisez ce paramètre pour le concaténer avant le prompt système de l'utilisateur",
@@ -1584,6 +1585,7 @@
"成功": "Succès",
"成功兑换额度:": "Montant de l'échange réussi :",
"成功后切换亲和": "Changer l'affinité en cas de succès",
"渠道禁用后保留亲和": "Conserver l'affinité lorsque le canal est désactivé",
"成功时自动启用通道": "Activer le canal en cas de succès",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "J'ai compris que la désactivation de l'authentification à deux facteurs supprimera définitivement tous les paramètres et codes de sauvegarde associés, cette opération ne peut pas être annulée",
"我已阅读并同意": "J'ai lu et j'accepte",
+2
View File
@@ -1180,6 +1180,7 @@
"套餐的基本信息和定价": "プランの基本情報と価格",
"如:大带宽批量分析图片推荐": "例:広帯域での画像一括分析に推奨",
"如:香港线路": "例:香港回線",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "有効にすると、アフィニティチャネルが無効化された、または現在のグループ/モデルで利用できなくなった場合でも、そのアフィニティエントリを保持します。無効にすると、エントリを削除して別のチャネルを選択します。",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "アフィニティチャネルが失敗した場合、別のチャネルでリトライが成功すると、アフィニティが成功したチャネルに更新されます。",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "New APIなどのリレープロジェクトに接続する場合は、OpenAIタイプを利用してください。設定内容を熟知している場合を除き、このタイプは利用しないでください",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "ユーザーリクエストにシステムプロンプトが含まれている場合、この設定内容がユーザーのシステムプロンプトの前に追加されます",
@@ -1555,6 +1556,7 @@
"成功": "成功",
"成功兑换额度:": "引き換え額:",
"成功后切换亲和": "成功時にアフィニティを切り替え",
"渠道禁用后保留亲和": "チャネル無効時にアフィニティを保持",
"成功时自动启用通道": "成功時にチャネルを自動的に有効にする",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "2要素認証を無効にすると、すべての関連設定とバックアップコードが永久に削除され、この操作は元に戻すことができないことを理解しました",
"我已阅读并同意": "読んで同意します",
+2
View File
@@ -1201,6 +1201,7 @@
"套餐的基本信息和定价": "Основная информация и цена плана",
"如:大带宽批量分析图片推荐": "Например: рекомендуется для пакетного анализа изображений с большой пропускной способностью",
"如:香港线路": "Например: Гонконгская линия",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "Если включено, запись аффинити сохраняется, даже когда канал аффинити отключён или больше не подходит для текущей группы/модели. Если выключено, запись будет удалена и выбран другой канал.",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "Если канал аффинити не сработал, после успешного повтора на другом канале аффинити будет обновлена на успешный канал.",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "Если вы интегрируетесь с восходящими проектами пересылки, такими как One API или New API, используйте тип OpenAI, не используйте этот тип, если вы не знаете, что делаете.",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "Если запрос пользователя содержит системный промпт, используйте эту настройку для добавления перед системным промптом пользователя",
@@ -1602,6 +1603,7 @@
"成功": "Успешно",
"成功兑换额度:": "Успешно обменяно квота: ",
"成功后切换亲和": "Переключить аффинити при успехе",
"渠道禁用后保留亲和": "Сохранять аффинити при отключении канала",
"成功时自动启用通道": "Автоматически включать канал при успехе",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "Я понимаю, что отключение двухфакторной аутентификации приведет к постоянному удалению всех связанных настроек и резервных кодов, и эта операция не может быть отменена",
"我已阅读并同意": "Я прочитал(а) и согласен(на)",
+2
View File
@@ -1181,6 +1181,7 @@
"套餐的基本信息和定价": "Thông tin cơ bản và giá của gói",
"如:大带宽批量分析图片推荐": "ví dụ: Phân tích hàng loạt băng thông lớn đề xuất hình ảnh",
"如:香港线路": "ví dụ: Tuyến Hồng Kông",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "Khi bật, giữ mục ưu ái ngay cả khi kênh ưu ái bị tắt hoặc không còn dùng được cho nhóm/mô hình hiện tại. Khi tắt, mục đó sẽ bị xóa và kênh khác sẽ được chọn.",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "Nếu kênh ưu ái thất bại, sau khi thử lại thành công trên kênh khác, ưu ái sẽ được cập nhật sang kênh thành công.",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "Nếu bạn đang kết nối với các dự án chuyển tiếp One API hoặc New API thượng nguồn, vui lòng sử dụng loại OpenAI. Đừng sử dụng loại này trừ khi bạn biết mình đang làm gì.",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "Nếu yêu cầu của người dùng chứa từ nhắc hệ thống, cài đặt này sẽ được nối vào trước từ nhắc hệ thống của người dùng",
@@ -1556,6 +1557,7 @@
"成功": "Thành công",
"成功兑换额度:": "Số tiền đổi thành công:",
"成功后切换亲和": "Chuyển ưu ái khi thành công",
"渠道禁用后保留亲和": "Giữ ưu ái khi kênh bị tắt",
"成功时自动启用通道": "Bật kênh khi thành công",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "Tôi đã hiểu rằng việc vô hiệu hóa xác thực hai yếu tố sẽ xóa vĩnh viễn tất cả các cài đặt liên quan và mã dự phòng, thao tác này không thể hoàn tác",
"我已阅读并同意": "Tôi đã đọc và đồng ý với",
+2
View File
@@ -1170,6 +1170,7 @@
"套餐的基本信息和定价": "套餐的基本信息和定价",
"如:大带宽批量分析图片推荐": "如:大带宽批量分析图片推荐",
"如:香港线路": "如:香港线路",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面",
@@ -1541,6 +1542,7 @@
"成功": "成功",
"成功兑换额度:": "成功兑换额度:",
"成功后切换亲和": "成功后切换亲和",
"渠道禁用后保留亲和": "渠道禁用后保留亲和",
"成功时自动启用通道": "成功时自动启用通道",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销",
"我已阅读并同意": "我已阅读并同意",
+2
View File
@@ -1179,6 +1179,7 @@
"套餐的基本信息和定价": "訂閱的基本資訊和定價",
"如:大带宽批量分析图片推荐": "如:大頻寬批量分析圖片推薦",
"如:香港线路": "如:香港線路",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "開啟後,親和到的渠道被停用,或不再適用於目前分組/模型時,仍保留這條親和;關閉時會刪除並重新選擇渠道。",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "如果你對接的是上游One API或者New API等轉發項目,請使用OpenAI類型,不要使用此類型,除非你知道你在做什麼。",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "如果使用者請求中包含系統提示詞,則使用此設定拼接到使用者的系統提示詞前面",
@@ -1551,6 +1552,7 @@
"成功": "成功",
"成功兑换额度:": "成功兌換額度:",
"成功后切换亲和": "",
"渠道禁用后保留亲和": "渠道停用後保留親和",
"成功时自动启用通道": "成功時自動啟用通道",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "我已瞭解禁用兩步驗證將永久刪除所有相關設定和備用碼,此操作不可撤銷",
"我已阅读并同意": "我已閱讀並同意",
+28 -6
View File
@@ -31,18 +31,40 @@ body {
background-color: var(--semi-color-bg-0);
}
/* 桌面端禁止 body 纵向滚动 - 防止 VChart tooltip 触发页面滚动条 */
@media (min-width: 768px) {
body {
overflow-y: hidden;
}
.app-layout {
min-height: 100vh;
min-height: 100dvh;
}
.app-layout {
.app-layout-fixed {
height: 100vh;
height: 100dvh;
}
.public-page-content {
display: flex;
flex-direction: column;
}
.classic-page-fill {
flex: 1 0 auto;
min-height: 100%;
}
.classic-home-page,
.classic-home-default {
display: flex;
flex-direction: column;
}
.classic-home-default {
flex: 1 0 auto;
}
.classic-home-hero {
flex: 1 0 auto;
}
.app-sider {
height: calc(100vh - 64px);
height: calc(100dvh - 64px);
+2
View File
@@ -1,3 +1,5 @@
import '@douyinfe/semi-ui/react19-adapter';
/*
Copyright (C) 2025 QuantumNous
+8 -3
View File
@@ -133,9 +133,9 @@ const About = () => {
);
return (
<div className='mt-[60px] px-2'>
<div className='classic-page-fill flex flex-col pt-[60px] px-2'>
{aboutLoaded && about === '' ? (
<div className='flex justify-center items-center h-screen p-8'>
<div className='flex flex-1 justify-center items-center p-8'>
<Empty
image={
<IllustrationConstruction style={{ width: 150, height: 150 }} />
@@ -156,7 +156,12 @@ const About = () => {
{about.startsWith('https://') ? (
<iframe
src={about}
style={{ width: '100%', height: '100vh', border: 'none' }}
style={{
width: '100%',
flex: '1 1 auto',
minHeight: 0,
border: 'none',
}}
/>
) : (
<div
+1 -1
View File
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
const Forbidden = () => {
const { t } = useTranslation();
return (
<div className='flex justify-center items-center h-screen p-8'>
<div className='classic-page-fill flex justify-center items-center p-8'>
<Empty
image={<IllustrationNoAccess style={{ width: 250, height: 250 }} />}
darkModeImage={
+6 -6
View File
@@ -149,20 +149,20 @@ const Home = () => {
}, [endpointItems.length]);
return (
<div className='w-full overflow-x-hidden'>
<div className='classic-page-fill classic-home-page w-full overflow-x-hidden'>
<NoticeModal
visible={noticeVisible}
onClose={() => setNoticeVisible(false)}
isMobile={isMobile}
/>
{homePageContentLoaded && homePageContent === '' ? (
<div className='w-full overflow-x-hidden'>
<div className='classic-home-default w-full overflow-x-hidden'>
{/* Banner 部分 */}
<div className='w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden'>
<div className='classic-home-hero w-full border-b border-semi-color-border relative overflow-x-hidden'>
{/* 背景模糊晕染球 */}
<div className='blur-ball blur-ball-indigo' />
<div className='blur-ball blur-ball-teal' />
<div className='flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32 mt-10'>
<div className='flex items-center justify-center px-4 pt-24 pb-8'>
{/* 居中内容区 */}
<div className='flex flex-col items-center justify-center text-center max-w-4xl mx-auto'>
<div className='flex flex-col items-center justify-center mb-6 md:mb-8'>
@@ -335,11 +335,11 @@ const Home = () => {
</div>
</div>
) : (
<div className='overflow-x-hidden w-full'>
<div className='classic-page-fill overflow-x-hidden w-full'>
{homePageContent.startsWith('https://') ? (
<iframe
src={homePageContent}
className='w-full h-screen border-none'
className='w-full h-full border-none'
/>
) : (
<div
+1 -1
View File
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
const NotFound = () => {
const { t } = useTranslation();
return (
<div className='flex justify-center items-center h-screen p-8'>
<div className='classic-page-fill flex justify-center items-center p-8'>
<Empty
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
darkModeImage={
+1 -1
View File
@@ -208,7 +208,7 @@ export default function SettingGlobalModel(props) {
<Row>
<Col span={24}>
<Form.TextArea
label={t('禁用思考处理的模型列表')}
label={t('不自动处理思考后缀的模型列表')}
field={'global.thinking_model_blacklist'}
placeholder={t('例如:') + '\n' + thinkingExample}
rows={4}
@@ -62,6 +62,8 @@ import ParamOverrideEditorModal from '../../../components/table/channels/modals/
const KEY_ENABLED = 'channel_affinity_setting.enabled';
const KEY_SWITCH_ON_SUCCESS = 'channel_affinity_setting.switch_on_success';
const KEY_KEEP_ON_CHANNEL_DISABLED =
'channel_affinity_setting.keep_on_channel_disabled';
const KEY_MAX_ENTRIES = 'channel_affinity_setting.max_entries';
const KEY_DEFAULT_TTL = 'channel_affinity_setting.default_ttl_seconds';
const KEY_RULES = 'channel_affinity_setting.rules';
@@ -241,6 +243,7 @@ export default function SettingsChannelAffinity(props) {
const [inputs, setInputs] = useState({
[KEY_ENABLED]: false,
[KEY_SWITCH_ON_SUCCESS]: true,
[KEY_KEEP_ON_CHANNEL_DISABLED]: false,
[KEY_MAX_ENTRIES]: 100000,
[KEY_DEFAULT_TTL]: 3600,
[KEY_RULES]: '[]',
@@ -858,6 +861,7 @@ export default function SettingsChannelAffinity(props) {
![
KEY_ENABLED,
KEY_SWITCH_ON_SUCCESS,
KEY_KEEP_ON_CHANNEL_DISABLED,
KEY_MAX_ENTRIES,
KEY_DEFAULT_TTL,
KEY_RULES,
@@ -868,6 +872,8 @@ export default function SettingsChannelAffinity(props) {
currentInputs[key] = toBoolean(props.options[key]);
else if (key === KEY_SWITCH_ON_SUCCESS)
currentInputs[key] = toBoolean(props.options[key]);
else if (key === KEY_KEEP_ON_CHANNEL_DISABLED)
currentInputs[key] = toBoolean(props.options[key]);
else if (key === KEY_MAX_ENTRIES)
currentInputs[key] = Number(props.options[key] || 0) || 0;
else if (key === KEY_DEFAULT_TTL)
@@ -1003,6 +1009,25 @@ export default function SettingsChannelAffinity(props) {
)}
</Text>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field={KEY_KEEP_ON_CHANNEL_DISABLED}
label={t('渠道禁用后保留亲和')}
checkedText='|'
uncheckedText='O'
onChange={(value) =>
setInputs({
...inputs,
[KEY_KEEP_ON_CHANNEL_DISABLED]: value,
})
}
/>
<Text type='tertiary' size='small'>
{t(
'开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。',
)}
</Text>
</Col>
</Row>
<Divider style={{ marginTop: 12, marginBottom: 12 }} />
-107
View File
@@ -1,107 +0,0 @@
/*
Copyright (C) 2025 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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import react from '@vitejs/plugin-react';
import { defineConfig, transformWithEsbuild } from 'vite';
import pkg from '@douyinfe/vite-plugin-semi';
import path from 'path';
import { codeInspectorPlugin } from 'code-inspector-plugin';
const { vitePluginSemi } = pkg;
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
plugins: [
codeInspectorPlugin({
bundler: 'vite',
}),
{
name: 'treat-js-files-as-jsx',
async transform(code, id) {
if (!/src\/.*\.js$/.test(id)) {
return null;
}
// Use the exposed transform from vite, instead of directly
// transforming with esbuild
return transformWithEsbuild(code, id, {
loader: 'jsx',
jsx: 'automatic',
});
},
},
react(),
vitePluginSemi({
cssLayer: true,
}),
],
optimizeDeps: {
force: true,
esbuildOptions: {
loader: {
'.js': 'jsx',
'.json': 'json',
},
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
'react-core': ['react', 'react-dom', 'react-router-dom'],
'semi-ui': ['@douyinfe/semi-icons', '@douyinfe/semi-ui'],
tools: ['axios', 'history', 'marked'],
'react-components': [
'react-dropzone',
'react-fireworks',
'react-telegram-login',
'react-toastify',
'react-turnstile',
],
i18n: [
'i18next',
'react-i18next',
'i18next-browser-languagedetector',
],
},
},
},
},
server: {
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/mj': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/pg': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});
+10 -10
View File
@@ -24,7 +24,7 @@
"@hookform/resolvers": "^5.4.0",
"@hugeicons/core-free-icons": "^4.1.4",
"@hugeicons/react": "^1.1.6",
"@lobehub/icons": "^5.8.0",
"@lobehub/icons": "catalog:",
"@tailwindcss/postcss": "^4.3.0",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-router": "^1.170.8",
@@ -34,12 +34,12 @@
"@visactor/vchart": "^2.0.22",
"ai": "^6.0.191",
"auto-skeleton-react": "^1.0.5",
"axios": "^1.16.1",
"axios": "catalog:",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"clsx": "catalog:",
"cmdk": "^1.1.1",
"date-fns": "^4.3.0",
"dayjs": "^1.11.20",
"dayjs": "catalog:",
"i18next": "^26.2.0",
"i18next-browser-languagedetector": "^8.2.1",
"input-otp": "^1.4.2",
@@ -47,22 +47,22 @@
"motion": "^12.40.0",
"nanoid": "^5.1.11",
"next-themes": "^0.4.6",
"qrcode.react": "^4.2.0",
"qrcode.react": "catalog:",
"react": "^19.2.6",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.6",
"react-hook-form": "^7.76.1",
"react-i18next": "^17.0.8",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"react-icons": "catalog:",
"react-markdown": "catalog:",
"react-resizable-panels": "^4.11.2",
"react-top-loading-bar": "^3.0.2",
"recharts": "3.8.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-gfm": "catalog:",
"shiki": "^4.1.0",
"sonner": "^2.0.7",
"sse.js": "^2.8.0",
"sse.js": "catalog:",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
@@ -92,7 +92,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"knip": "^6.14.2",
"prettier": "^3.8.3",
"prettier": "catalog:",
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn": "^4.8.0",
"typescript": "~6.0.3",
+1
View File
@@ -65,6 +65,7 @@ export default defineConfig(({ envMode }) => {
},
server: {
host: '0.0.0.0',
strictPort: true,
proxy: devProxy,
},
output: {
+99 -1
View File
@@ -31,7 +31,99 @@ import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
const Form = FormProvider
type FormRootContextValue = {
id: string
}
const FormRootContext = React.createContext<FormRootContextValue | null>(null)
function getFormScopedSelector(formId: string, selector: string): string {
return `[data-form-root="${formId}"]${selector}`
}
function hasFormErrors(errors: unknown): boolean {
return (
typeof errors === 'object' &&
errors !== null &&
Object.keys(errors).length > 0
)
}
function getFirstFormErrorTarget(
invalidControl: HTMLElement | null,
errorMessage: HTMLElement | null
): HTMLElement | null {
if (!invalidControl) return errorMessage
if (!errorMessage) return invalidControl
const position = invalidControl.compareDocumentPosition(errorMessage)
return position & Node.DOCUMENT_POSITION_PRECEDING
? errorMessage
: invalidControl
}
function FormValidationFocus() {
const formContext = React.useContext(FormRootContext)
const { control } = useFormContext()
const { errors, submitCount } = useFormState({ control })
const handledSubmitCountRef = React.useRef(0)
React.useEffect(() => {
if (!formContext || submitCount === 0 || !hasFormErrors(errors)) return
if (handledSubmitCountRef.current === submitCount) return
handledSubmitCountRef.current = submitCount
const animationFrameId = window.requestAnimationFrame(() => {
const invalidControl = document.querySelector<HTMLElement>(
getFormScopedSelector(formContext.id, '[aria-invalid="true"]')
)
const errorMessage = document.querySelector<HTMLElement>(
getFormScopedSelector(formContext.id, '[data-slot="form-message"]')
)
const target = getFirstFormErrorTarget(invalidControl, errorMessage)
if (!target) return
const formItem = target.closest<HTMLElement>(
getFormScopedSelector(formContext.id, '[data-slot="form-item"]')
)
const scrollTarget = formItem ?? target
const focusTarget =
target === invalidControl
? invalidControl
: (formItem?.querySelector<HTMLElement>(
'[aria-invalid="true"], input, textarea, select, button, [tabindex]:not([tabindex="-1"])'
) ?? null)
scrollTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
focusTarget?.focus({ preventScroll: true })
})
return () => window.cancelAnimationFrame(animationFrameId)
}, [errors, formContext, submitCount])
return null
}
function Form<TFieldValues extends FieldValues = FieldValues>({
children,
...props
}: React.ComponentProps<typeof FormProvider<TFieldValues>>) {
const reactId = React.useId()
const id = React.useMemo(
() => `form-${reactId.replaceAll(/[^a-zA-Z0-9_-]/g, '_')}`,
[reactId]
)
return (
<FormRootContext.Provider value={{ id }}>
<FormProvider {...props}>
<FormValidationFocus />
{children}
</FormProvider>
</FormRootContext.Provider>
)
}
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
@@ -90,11 +182,13 @@ const FormItemContext = React.createContext<FormItemContextValue>(
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId()
const formContext = React.useContext(FormRootContext)
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot='form-item'
data-form-root={formContext?.id}
className={cn('grid gap-2', className)}
{...props}
/>
@@ -124,11 +218,13 @@ function FormControl({
...props
}: { children: React.ReactElement } & Record<string, unknown>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
const formContext = React.useContext(FormRootContext)
return useRender({
render: children,
props: {
'data-slot': 'form-control',
'data-form-root': formContext?.id,
id: formItemId,
'aria-describedby': !error
? `${formDescriptionId}`
@@ -154,6 +250,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField()
const formContext = React.useContext(FormRootContext)
const { t } = useTranslation()
const body = error ? String(error?.message ?? '') : props.children
@@ -166,6 +263,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot='form-message'
data-form-root={formContext?.id}
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
@@ -24,7 +24,7 @@ import {
useCallback,
useRef,
} from 'react'
import { useForm } from 'react-hook-form'
import { type SubmitErrorHandler, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
@@ -140,6 +140,7 @@ import {
hasModelConfigChanged,
findMissingModelsInMapping,
validateModelMappingJson,
hasAdvancedSettingsErrors,
} from '../../lib'
import {
collectInvalidStatusCodeEntries,
@@ -204,7 +205,6 @@ function readAdvancedSettingsPreference(): boolean {
function hasAdvancedSettingsValues(values: ChannelFormValues): boolean {
return Boolean(
values.model_mapping?.trim() ||
values.param_override?.trim() ||
values.header_override?.trim() ||
values.status_code_mapping?.trim() ||
@@ -1008,6 +1008,26 @@ export function ChannelMutateDrawer({
]
)
const handleAdvancedSettingsOpenChange = useCallback((nextOpen: boolean) => {
setAdvancedSettingsOpen(nextOpen)
if (typeof window !== 'undefined') {
window.localStorage.setItem(
ADVANCED_SETTINGS_EXPANDED_KEY,
String(nextOpen)
)
}
}, [])
const onInvalid: SubmitErrorHandler<ChannelFormValues> = useCallback(
(errors) => {
if (hasAdvancedSettingsErrors(errors)) {
handleAdvancedSettingsOpenChange(true)
}
toast.error(t('Please fix the highlighted fields before saving'))
},
[handleAdvancedSettingsOpenChange, t]
)
// Handle drawer close
const handleOpenChange = useCallback(
(v: boolean) => {
@@ -1020,16 +1040,6 @@ export function ChannelMutateDrawer({
[onOpenChange, form]
)
const handleAdvancedSettingsOpenChange = useCallback((nextOpen: boolean) => {
setAdvancedSettingsOpen(nextOpen)
if (typeof window !== 'undefined') {
window.localStorage.setItem(
ADVANCED_SETTINGS_EXPANDED_KEY,
String(nextOpen)
)
}
}, [])
return (
<>
<Sheet open={open} onOpenChange={handleOpenChange}>
@@ -1060,7 +1070,7 @@ export function ChannelMutateDrawer({
<Form {...form}>
<form
id='channel-form'
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
className={sideDrawerFormClassName('gap-5')}
>
{isChannelDetailLoading ? (
@@ -0,0 +1,66 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { FieldPath } from 'react-hook-form'
import type { ChannelFormValues } from './channel-form'
type ChannelFormErrorMap = Partial<
Record<FieldPath<ChannelFormValues>, unknown>
>
const ADVANCED_SETTINGS_FIELDS = new Set<FieldPath<ChannelFormValues>>([
'priority',
'weight',
'test_model',
'auto_ban',
'tag',
'remark',
'param_override',
'header_override',
'status_code_mapping',
'force_format',
'thinking_to_content',
'pass_through_body_enabled',
'proxy',
'system_prompt',
'system_prompt_override',
'allow_service_tier',
'disable_store',
'allow_safety_identifier',
'allow_include_obfuscation',
'allow_inference_geo',
'allow_speed',
'claude_beta_query',
'upstream_model_update_check_enabled',
'upstream_model_update_auto_sync_enabled',
'upstream_model_update_ignored_models',
])
export function isAdvancedSettingsField(
fieldName: string
): fieldName is FieldPath<ChannelFormValues> {
return ADVANCED_SETTINGS_FIELDS.has(fieldName as FieldPath<ChannelFormValues>)
}
export function hasAdvancedSettingsErrors(
errors: ChannelFormErrorMap
): boolean {
return Object.keys(errors).some((fieldName) =>
isAdvancedSettingsField(fieldName)
)
}
+1
View File
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
// Re-export all library functions
export * from './channel-actions'
export * from './channel-form-errors'
export * from './channel-form'
export * from './channel-type-config'
export * from './channel-utils'
@@ -189,6 +189,7 @@ export function CCSwitchDialog(props: Props) {
onValueChange={setName}
placeholder={currentConfig.defaultName}
emptyText=''
allowCustomValue={true}
/>
</div>
@@ -196,6 +196,7 @@ export function ModelMutateDrawer({
'grok.violation_deduction_amount': 0,
'channel_affinity_setting.enabled': false,
'channel_affinity_setting.switch_on_success': true,
'channel_affinity_setting.keep_on_channel_disabled': false,
'channel_affinity_setting.max_entries': 100000,
'channel_affinity_setting.default_ttl_seconds': 3600,
'channel_affinity_setting.rules': '[]',
+4 -3
View File
@@ -56,8 +56,9 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
const tags = parseTags(props.model.tags)
const groups = props.model.enable_groups || []
const endpoints = props.model.supported_endpoint_types || []
const vendorIcon = props.model.vendor_icon
? getLobeIcon(props.model.vendor_icon, 28)
const modelIconKey = props.model.icon || props.model.vendor_icon
const modelIcon = modelIconKey
? getLobeIcon(modelIconKey, 28)
: null
const initial = props.model.model_name?.charAt(0).toUpperCase() || '?'
const isDynamicPricing =
@@ -97,7 +98,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
<div className='flex items-start justify-between gap-2.5 sm:gap-3'>
<div className='flex min-w-0 items-start gap-2.5 sm:gap-3'>
<div className='bg-muted/40 flex size-9 shrink-0 items-center justify-center rounded-lg sm:size-10 sm:rounded-xl'>
{vendorIcon || (
{modelIcon || (
<span className='text-muted-foreground text-sm font-bold'>
{initial}
</span>
@@ -268,8 +268,9 @@ function OverviewSummaryGrid(props: { model: PricingModel }) {
function ModelHeader(props: { model: PricingModel }) {
const { t } = useTranslation()
const model = props.model
const vendorIcon = model.vendor_icon
? getLobeIcon(model.vendor_icon, 20)
const modelIconKey = model.icon || model.vendor_icon
const modelIcon = modelIconKey
? getLobeIcon(modelIconKey, 20)
: null
const description = model.description || model.vendor_description || null
const tags = parseTags(model.tags)
@@ -281,7 +282,7 @@ function ModelHeader(props: { model: PricingModel }) {
return (
<header className='pb-4'>
<div className='flex items-center gap-2.5'>
{vendorIcon}
{modelIcon}
<h1 className='font-mono text-xl font-bold tracking-tight sm:text-2xl'>
{model.model_name}
</h1>
@@ -106,13 +106,14 @@ export function usePricingColumns(
),
cell: ({ row }) => {
const model = row.original
const vendorIcon = model.vendor_icon
? getLobeIcon(model.vendor_icon, 14)
const modelIconKey = model.icon || model.vendor_icon
const modelIcon = modelIconKey
? getLobeIcon(modelIconKey, 14)
: null
return (
<div className='flex min-w-[200px] items-center gap-2'>
{vendorIcon}
{modelIcon}
<span className='truncate font-mono text-sm font-medium'>
{model.model_name}
</span>
-3
View File
@@ -174,9 +174,6 @@ export function Pricing() {
/>
<PageTransition className='relative mx-auto w-full max-w-[1800px] px-3 pt-16 pb-8 sm:px-6 sm:pt-20 sm:pb-10 xl:px-8'>
<header className='mx-auto mb-5 max-w-3xl pt-5 text-center sm:mb-10 sm:pt-10'>
<p className='text-muted-foreground mb-3 text-xs font-medium tracking-widest uppercase'>
{t('Models Directory')}
</p>
<h1 className='text-[clamp(2rem,5.5vw,3.5rem)] leading-[1.15] font-bold tracking-tight'>
{t('Model Square')}
</h1>
+1
View File
@@ -31,6 +31,7 @@ export type PricingModel = {
id: number
model_name: string
description?: string
icon?: string
vendor_id?: number
vendor_name?: string
vendor_icon?: string
@@ -118,6 +118,11 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
variant='neutral'
copyable={false}
/>
<StatusBadge
label={`${t('User ID')} ${profile.id}`}
variant='info'
copyText={String(profile.id)}
/>
</div>
<div className='text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:gap-x-4 sm:text-sm'>
@@ -43,9 +43,6 @@ export function RankingsHero(props: RankingsHeroProps) {
return (
<section className='space-y-5'>
<div className='space-y-2'>
<p className='text-muted-foreground text-xs font-medium tracking-widest uppercase'>
{t('Leaderboards')}
</p>
<h1 className='text-[clamp(1.75rem,4vw,2.5rem)] leading-[1.15] font-bold tracking-tight'>
{t('Rankings')}
</h1>
@@ -111,6 +111,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
Math.ceil(Number(plan.price_amount || 0) * quotaPerUnit)
)
const userQuota = Math.max(0, Number(props.userQuota || 0))
const allowBalancePay = plan.allow_balance_pay !== false
const insufficientBalance = userQuota < balanceCost
const limitReached =
(props.purchaseLimit || 0) > 0 &&
@@ -232,6 +233,10 @@ export function SubscriptionPurchaseDialog(props: Props) {
}
const handlePayBalance = async () => {
if (!allowBalancePay) {
toast.error(t('This plan does not allow balance redemption'))
return
}
setPaying(true)
try {
const res = await paySubscriptionBalance({ plan_id: plan.id })
@@ -332,15 +337,27 @@ export function SubscriptionPurchaseDialog(props: Props) {
<span className='text-muted-foreground'>{t('Available')}</span>
<span>{formatQuota(userQuota)}</span>
</div>
{insufficientBalance && (
{!allowBalancePay ? (
<Alert variant='destructive'>
<AlertDescription>{t('Insufficient balance')}</AlertDescription>
<AlertDescription>
{t('This plan does not allow balance redemption')}
</AlertDescription>
</Alert>
) : (
insufficientBalance && (
<Alert variant='destructive'>
<AlertDescription>
{t('Insufficient balance')}
</AlertDescription>
</Alert>
)
)}
<Button
variant='outline'
onClick={handlePayBalance}
disabled={paying || limitReached || insufficientBalance}
disabled={
paying || limitReached || !allowBalancePay || insufficientBalance
}
>
{t('Pay with Balance')}
</Button>
@@ -461,6 +461,24 @@ export function SubscriptionsMutateDrawer({
</FormItem>
)}
/>
<FormField
control={form.control}
name='allow_balance_pay'
render={({ field }) => (
<FormItem className={sideDrawerSwitchItemClassName()}>
<FormLabel className='!mt-0'>
{t('Allow balance redemption')}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</SideDrawerSection>
@@ -39,6 +39,7 @@ export function getPlanFormSchema(t: TFunction) {
quota_reset_custom_seconds: z.coerce.number().min(0).optional(),
enabled: z.boolean(),
sort_order: z.coerce.number(),
allow_balance_pay: z.boolean(),
max_purchase_per_user: z.coerce.number().min(0),
total_amount: z.coerce.number().min(0),
upgrade_group: z.string().optional(),
@@ -61,6 +62,7 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = {
quota_reset_custom_seconds: 0,
enabled: true,
sort_order: 0,
allow_balance_pay: true,
max_purchase_per_user: 0,
total_amount: 0,
upgrade_group: '',
@@ -81,6 +83,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
quota_reset_custom_seconds: Number(plan.quota_reset_custom_seconds || 0),
enabled: plan.enabled !== false,
sort_order: Number(plan.sort_order || 0),
allow_balance_pay: plan.allow_balance_pay !== false,
max_purchase_per_user: Number(plan.max_purchase_per_user || 0),
total_amount: quotaUnitsToDollars(Number(plan.total_amount || 0)),
upgrade_group: plan.upgrade_group || '',
+1
View File
@@ -35,6 +35,7 @@ export const subscriptionPlanSchema = z.object({
quota_reset_custom_seconds: z.number().optional(),
enabled: z.boolean(),
sort_order: z.number(),
allow_balance_pay: z.boolean().optional().default(true),
max_purchase_per_user: z.number(),
total_amount: z.number(),
upgrade_group: z.string().optional(),
@@ -100,6 +100,9 @@ export function ChannelAffinitySection(props: Props) {
const [switchOnSuccess, setSwitchOnSuccess] = useState(
props.defaultValues['channel_affinity_setting.switch_on_success']
)
const [keepOnChannelDisabled, setKeepOnChannelDisabled] = useState(
props.defaultValues['channel_affinity_setting.keep_on_channel_disabled']
)
const [maxEntries, setMaxEntries] = useState(
props.defaultValues['channel_affinity_setting.max_entries']
)
@@ -136,6 +139,9 @@ export function ChannelAffinitySection(props: Props) {
setSwitchOnSuccess(
props.defaultValues['channel_affinity_setting.switch_on_success']
)
setKeepOnChannelDisabled(
props.defaultValues['channel_affinity_setting.keep_on_channel_disabled']
)
setMaxEntries(props.defaultValues['channel_affinity_setting.max_entries'])
setDefaultTtl(
props.defaultValues['channel_affinity_setting.default_ttl_seconds']
@@ -231,6 +237,14 @@ export function ChannelAffinitySection(props: Props) {
key: 'channel_affinity_setting.switch_on_success',
value: String(switchOnSuccess),
})
if (
keepOnChannelDisabled !==
props.defaultValues['channel_affinity_setting.keep_on_channel_disabled']
)
updates.push({
key: 'channel_affinity_setting.keep_on_channel_disabled',
value: String(keepOnChannelDisabled),
})
if (
maxEntries !==
props.defaultValues['channel_affinity_setting.max_entries']
@@ -397,6 +411,14 @@ export function ChannelAffinitySection(props: Props) {
'If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.'
)}
/>
<SettingsSwitchField
checked={keepOnChannelDisabled}
onCheckedChange={setKeepOnChannelDisabled}
label={t('Keep affinity when channel is disabled')}
description={t(
'When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.'
)}
/>
<Separator />

Some files were not shown because too many files have changed in this diff Show More