Merge remote-tracking branch 'upstream/main' into fix/mobile-usage-log-cost-alignment
# Conflicts: # web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx
This commit is contained in:
@@ -56,6 +56,8 @@
|
|||||||
# 对话超时设置
|
# 对话超时设置
|
||||||
# 所有请求超时时间,单位秒,默认为0,表示不限制
|
# 所有请求超时时间,单位秒,默认为0,表示不限制
|
||||||
# RELAY_TIMEOUT=0
|
# RELAY_TIMEOUT=0
|
||||||
|
# Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制
|
||||||
|
# RELAY_IDLE_CONN_TIMEOUT=90
|
||||||
# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
|
# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
|
||||||
# STREAMING_TIMEOUT=300
|
# STREAMING_TIMEOUT=300
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ assignees: ''
|
|||||||
|
|
||||||
- 文档:https://docs.newapi.ai/
|
- 文档:https://docs.newapi.ai/
|
||||||
- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
|
- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
|
||||||
|
- 开启透传后的转发相关反馈不接受 issue;透传模式会直接转发请求,请自行确认上游行为。
|
||||||
|
- 不接受 coding plan、逆向渠道等技术支持类 issue。
|
||||||
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
|
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
|
||||||
|
|
||||||
**您当前的 newapi 版本**
|
**您当前的 newapi 版本**
|
||||||
@@ -20,13 +22,18 @@ assignees: ''
|
|||||||
**提交确认**
|
**提交确认**
|
||||||
|
|
||||||
[//]: # (方框内删除已有的空格,填 x 号)
|
[//]: # (方框内删除已有的空格,填 x 号)
|
||||||
+ [ ] 我已确认目前没有类似 issue
|
- [ ] **非重复 issue:** 我已搜索现有 [Issues](https://github.com/QuantumNous/new-api/issues?q=is%3Aissue),确认目前没有类似 issue。
|
||||||
+ [ ] 我已完整查看过文档 https://docs.newapi.ai/ 和项目 README,尤其是常见问题部分
|
- [ ] **提交前必读:** 我已完整阅读上方“提交前必读”,并已查看文档 https://docs.newapi.ai/、项目 README 且向 AI 提问,确认这不是使用、配置或接入类问题。
|
||||||
+ [ ] 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
|
- [ ] **模板完整:** 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写。
|
||||||
+ [ ] 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
|
- [ ] **维护成本:** 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭。
|
||||||
|
|
||||||
**问题描述**
|
**问题描述**
|
||||||
|
|
||||||
|
请尽可能说明问题现象、影响范围,以及你判断它是程序问题而不是上游行为或使用问题的依据。
|
||||||
|
|
||||||
|
- 转发问题请尽可能说明渠道类型、转换格式、上游原生支持依据和服务端日志。
|
||||||
|
- 计费问题请尽可能附请求返回的 `usage` 示例。
|
||||||
|
|
||||||
**复现步骤**
|
**复现步骤**
|
||||||
|
|
||||||
**预期结果**
|
**预期结果**
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ assignees: ''
|
|||||||
|
|
||||||
- Docs: https://docs.newapi.ai/
|
- Docs: https://docs.newapi.ai/
|
||||||
- Usage questions first: https://deepwiki.com/QuantumNous/new-api
|
- 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.
|
- 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**
|
**Your current newapi version**
|
||||||
@@ -20,13 +22,18 @@ Please fill this in, for example: `v1.0.0`
|
|||||||
**Submission Checks**
|
**Submission Checks**
|
||||||
|
|
||||||
[//]: # (Remove the space in the box and fill with an x)
|
[//]: # (Remove the space in the box and fill with an x)
|
||||||
+ [ ] I have confirmed there are no similar issues
|
- [ ] **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.
|
||||||
+ [ ] I have thoroughly read the docs at https://docs.newapi.ai/ and the project README, especially the FAQ section
|
- [ ] **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.
|
||||||
+ [ ] I have not removed any guidance or section headings from this template and will complete it as requested
|
- [ ] **Template intact:** 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
|
- [ ] **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**
|
**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**
|
**Steps to Reproduce**
|
||||||
|
|
||||||
**Expected Result**
|
**Expected Result**
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ assignees: ''
|
|||||||
|
|
||||||
- 文档:https://docs.newapi.ai/
|
- 文档:https://docs.newapi.ai/
|
||||||
- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
|
- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
|
||||||
|
- 开启透传后的转发相关反馈不接受 issue;透传模式会直接转发请求,请自行确认上游行为。
|
||||||
|
- 不接受 coding plan、逆向渠道等技术支持类 issue。
|
||||||
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
|
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
|
||||||
|
|
||||||
**您当前的 newapi 版本**
|
**您当前的 newapi 版本**
|
||||||
@@ -20,10 +22,10 @@ assignees: ''
|
|||||||
**提交确认**
|
**提交确认**
|
||||||
|
|
||||||
[//]: # (方框内删除已有的空格,填 x 号)
|
[//]: # (方框内删除已有的空格,填 x 号)
|
||||||
+ [ ] 我已确认目前没有类似 issue
|
- [ ] **非重复 issue:** 我已搜索现有 [Issues](https://github.com/QuantumNous/new-api/issues?q=is%3Aissue),确认目前没有类似 issue。
|
||||||
+ [ ] 我已完整查看过文档 https://docs.newapi.ai/ 和项目 README,已确定现有版本无法满足需求
|
- [ ] **提交前必读:** 我已完整阅读上方“提交前必读”,并已查看文档 https://docs.newapi.ai/、项目 README 且向 AI 提问,确认这不是使用、配置或接入类问题,且现有版本无法满足需求。
|
||||||
+ [ ] 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
|
- [ ] **模板完整:** 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写。
|
||||||
+ [ ] 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
|
- [ ] **维护成本:** 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭。
|
||||||
|
|
||||||
**功能描述**
|
**功能描述**
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ assignees: ''
|
|||||||
|
|
||||||
- Docs: https://docs.newapi.ai/
|
- Docs: https://docs.newapi.ai/
|
||||||
- Usage questions first: https://deepwiki.com/QuantumNous/new-api
|
- 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.
|
- 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**
|
**Your current newapi version**
|
||||||
@@ -20,10 +22,10 @@ Please fill this in, for example: `v1.0.0`
|
|||||||
**Submission Checks**
|
**Submission Checks**
|
||||||
|
|
||||||
[//]: # (Remove the space in the box and fill with an x)
|
[//]: # (Remove the space in the box and fill with an x)
|
||||||
+ [ ] I have confirmed there are no similar issues
|
- [ ] **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.
|
||||||
+ [ ] I have thoroughly read the docs at https://docs.newapi.ai/ and the project README, and confirmed the current version cannot meet my needs
|
- [ ] **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.
|
||||||
+ [ ] I have not removed any guidance or section headings from this template and will complete it as requested
|
- [ ] **Template intact:** 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
|
- [ ] **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**
|
**Feature Description**
|
||||||
|
|
||||||
|
|||||||
@@ -33,16 +33,18 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CI: ""
|
CI: ""
|
||||||
run: |
|
run: |
|
||||||
cd web/default
|
cd web
|
||||||
bun install
|
bun install --frozen-lockfile
|
||||||
|
cd default
|
||||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
cd ../..
|
cd ../..
|
||||||
- name: Build Frontend (classic)
|
- name: Build Frontend (classic)
|
||||||
env:
|
env:
|
||||||
CI: ""
|
CI: ""
|
||||||
run: |
|
run: |
|
||||||
cd web/classic
|
cd web
|
||||||
bun install
|
bun install --frozen-lockfile
|
||||||
|
cd classic
|
||||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
cd ../..
|
cd ../..
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
@@ -91,16 +93,18 @@ jobs:
|
|||||||
CI: ""
|
CI: ""
|
||||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||||
run: |
|
run: |
|
||||||
cd web/default
|
cd web
|
||||||
bun install
|
bun install --frozen-lockfile
|
||||||
|
cd default
|
||||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
cd ../..
|
cd ../..
|
||||||
- name: Build Frontend (classic)
|
- name: Build Frontend (classic)
|
||||||
env:
|
env:
|
||||||
CI: ""
|
CI: ""
|
||||||
run: |
|
run: |
|
||||||
cd web/classic
|
cd web
|
||||||
bun install
|
bun install --frozen-lockfile
|
||||||
|
cd classic
|
||||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
cd ../..
|
cd ../..
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
@@ -146,16 +150,18 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CI: ""
|
CI: ""
|
||||||
run: |
|
run: |
|
||||||
cd web/default
|
cd web
|
||||||
bun install
|
bun install --frozen-lockfile
|
||||||
|
cd default
|
||||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
cd ../..
|
cd ../..
|
||||||
- name: Build Frontend (classic)
|
- name: Build Frontend (classic)
|
||||||
env:
|
env:
|
||||||
CI: ""
|
CI: ""
|
||||||
run: |
|
run: |
|
||||||
cd web/classic
|
cd web
|
||||||
bun install
|
bun install --frozen-lockfile
|
||||||
|
cd classic
|
||||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
cd ../..
|
cd ../..
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
|
|||||||
@@ -35,3 +35,4 @@ data/
|
|||||||
.test
|
.test
|
||||||
token_estimator_test.go
|
token_estimator_test.go
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
|
.playwright-mcp
|
||||||
|
|||||||
+18
-16
@@ -1,22 +1,24 @@
|
|||||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
|
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build/web
|
||||||
COPY web/default/package.json .
|
COPY web/package.json web/bun.lock ./
|
||||||
COPY web/default/bun.lock .
|
COPY web/default/package.json ./default/package.json
|
||||||
RUN bun install
|
COPY web/classic/package.json ./classic/package.json
|
||||||
COPY ./web/default .
|
RUN bun install --frozen-lockfile
|
||||||
COPY ./VERSION .
|
COPY ./web/default ./default
|
||||||
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
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
|
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build/web
|
||||||
COPY web/classic/package.json .
|
COPY web/package.json web/bun.lock ./
|
||||||
COPY web/classic/bun.lock .
|
COPY web/default/package.json ./default/package.json
|
||||||
RUN bun install
|
COPY web/classic/package.json ./classic/package.json
|
||||||
COPY ./web/classic .
|
RUN bun install --frozen-lockfile
|
||||||
COPY ./VERSION .
|
COPY ./web/classic ./classic
|
||||||
RUN VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
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
|
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
|
||||||
ENV GO111MODULE=on CGO_ENABLED=0
|
ENV GO111MODULE=on CGO_ENABLED=0
|
||||||
@@ -32,8 +34,8 @@ ADD go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=builder /build/dist ./web/default/dist
|
COPY --from=builder /build/web/default/dist ./web/default/dist
|
||||||
COPY --from=builder-classic /build/dist ./web/classic/dist
|
COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist
|
||||||
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||||
|
|
||||||
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ docker run --name new-api -d --restart always \
|
|||||||
| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - |
|
| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - |
|
||||||
| `SQL_DSN` | Database connection string | - |
|
| `SQL_DSN` | Database connection string | - |
|
||||||
| `REDIS_CONN_STRING` | Redis 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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ var BatchUpdateInterval int
|
|||||||
|
|
||||||
var RelayTimeout int // unit is second
|
var RelayTimeout int // unit is second
|
||||||
|
|
||||||
|
var RelayIdleConnTimeout int // unit is second
|
||||||
var RelayMaxIdleConns int
|
var RelayMaxIdleConns int
|
||||||
var RelayMaxIdleConnsPerHost int
|
var RelayMaxIdleConnsPerHost int
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ func InitEnv() {
|
|||||||
SyncFrequency = GetEnvOrDefault("SYNC_FREQUENCY", 60)
|
SyncFrequency = GetEnvOrDefault("SYNC_FREQUENCY", 60)
|
||||||
BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5)
|
BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5)
|
||||||
RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0)
|
RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0)
|
||||||
|
RelayIdleConnTimeout = GetEnvOrDefault("RELAY_IDLE_CONN_TIMEOUT", 90)
|
||||||
RelayMaxIdleConns = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS", 500)
|
RelayMaxIdleConns = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS", 500)
|
||||||
RelayMaxIdleConnsPerHost = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS_PER_HOST", 100)
|
RelayMaxIdleConnsPerHost = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS_PER_HOST", 100)
|
||||||
|
|
||||||
@@ -135,6 +136,7 @@ func initConstantEnv() {
|
|||||||
constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 128)
|
constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 128)
|
||||||
// MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨
|
// MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨
|
||||||
constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 128)
|
constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 128)
|
||||||
|
constant.AnonymousRequestBodyLimitKB = GetEnvOrDefault("ANONYMOUS_REQUEST_BODY_LIMIT_KB", 512)
|
||||||
// ForceStreamOption 覆盖请求参数,强制返回usage信息
|
// ForceStreamOption 覆盖请求参数,强制返回usage信息
|
||||||
constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true)
|
constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true)
|
||||||
constant.CountToken = GetEnvOrDefaultBool("CountToken", true)
|
constant.CountToken = GetEnvOrDefaultBool("CountToken", true)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ var GetMediaToken bool
|
|||||||
var GetMediaTokenNotStream bool
|
var GetMediaTokenNotStream bool
|
||||||
var UpdateTask bool
|
var UpdateTask bool
|
||||||
var MaxRequestBodyMB int
|
var MaxRequestBodyMB int
|
||||||
|
var AnonymousRequestBodyLimitKB int
|
||||||
var AzureDefaultAPIVersion string
|
var AzureDefaultAPIVersion string
|
||||||
var NotifyLimitCount int
|
var NotifyLimitCount int
|
||||||
var NotificationLimitDurationMinute int
|
var NotificationLimitDurationMinute int
|
||||||
|
|||||||
@@ -814,7 +814,7 @@ func buildTestRequest(model string, endpointType string, channel *model.Channel,
|
|||||||
testRequest.StreamOptions = &dto.StreamOptions{IncludeUsage: true}
|
testRequest.StreamOptions = &dto.StreamOptions{IncludeUsage: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(model, "o") {
|
if dto.IsOpenAIReasoningOModel(model) {
|
||||||
testRequest.MaxCompletionTokens = lo.ToPtr(uint(16))
|
testRequest.MaxCompletionTokens = lo.ToPtr(uint(16))
|
||||||
} else if strings.Contains(model, "thinking") {
|
} else if strings.Contains(model, "thinking") {
|
||||||
if !strings.Contains(model, "claude") {
|
if !strings.Contains(model, "claude") {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func GetSubscriptionPlans(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||||
for _, p := range plans {
|
for _, p := range plans {
|
||||||
|
p.NormalizeDefaults()
|
||||||
result = append(result, SubscriptionPlanDTO{
|
result = append(result, SubscriptionPlanDTO{
|
||||||
Plan: p,
|
Plan: p,
|
||||||
})
|
})
|
||||||
@@ -125,6 +126,7 @@ func AdminListSubscriptionPlans(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||||
for _, p := range plans {
|
for _, p := range plans {
|
||||||
|
p.NormalizeDefaults()
|
||||||
result = append(result, SubscriptionPlanDTO{
|
result = append(result, SubscriptionPlanDTO{
|
||||||
Plan: p,
|
Plan: p,
|
||||||
})
|
})
|
||||||
@@ -163,6 +165,9 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
|||||||
req.Plan.Currency = "USD"
|
req.Plan.Currency = "USD"
|
||||||
}
|
}
|
||||||
req.Plan.Currency = "USD"
|
req.Plan.Currency = "USD"
|
||||||
|
if req.Plan.AllowBalancePay == nil {
|
||||||
|
req.Plan.AllowBalancePay = common.GetPointer(true)
|
||||||
|
}
|
||||||
if req.Plan.DurationUnit == "" {
|
if req.Plan.DurationUnit == "" {
|
||||||
req.Plan.DurationUnit = model.SubscriptionDurationMonth
|
req.Plan.DurationUnit = model.SubscriptionDurationMonth
|
||||||
}
|
}
|
||||||
@@ -279,6 +284,9 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
|||||||
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
|
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
|
||||||
"updated_at": common.GetTimestamp(),
|
"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 {
|
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ services:
|
|||||||
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
|
- 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)
|
- 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)
|
# - 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!!!!!!!)
|
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!)
|
||||||
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
|
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
|
||||||
# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID)
|
# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID)
|
||||||
|
|||||||
+12
-2
@@ -213,12 +213,22 @@ func (r *GeneralOpenAIRequest) ToMap() map[string]any {
|
|||||||
return result
|
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 {
|
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") {
|
if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") {
|
||||||
return "developer"
|
return "developer"
|
||||||
}
|
}
|
||||||
} else if strings.HasPrefix(r.Model, "gpt-5") {
|
} else if IsOpenAIGPT5Model(r.Model) {
|
||||||
return "developer"
|
return "developer"
|
||||||
}
|
}
|
||||||
return "system"
|
return "system"
|
||||||
|
|||||||
@@ -71,3 +71,27 @@ func TestOpenAIResponsesRequestPreserveExplicitZeroValues(t *testing.T) {
|
|||||||
require.True(t, gjson.GetBytes(encoded, "stream").Exists())
|
require.True(t, gjson.GetBytes(encoded, "stream").Exists())
|
||||||
require.True(t, gjson.GetBytes(encoded, "top_p").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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
FRONTEND_DIR = ./web/default
|
FRONTEND_DIR = ./web/default
|
||||||
FRONTEND_CLASSIC_DIR = ./web/classic
|
FRONTEND_CLASSIC_DIR = ./web/classic
|
||||||
BACKEND_DIR = .
|
BACKEND_DIR = .
|
||||||
|
DEV_FRONTEND_DEFAULT_PORT ?= 5173
|
||||||
|
DEV_FRONTEND_CLASSIC_PORT ?= 5174
|
||||||
DEV_COMPOSE_FILE = docker-compose.dev.yml
|
DEV_COMPOSE_FILE = docker-compose.dev.yml
|
||||||
DEV_POSTGRES_SERVICE = postgres
|
DEV_POSTGRES_SERVICE = postgres
|
||||||
DEV_BACKEND_SERVICE = new-api
|
DEV_BACKEND_SERVICE = new-api
|
||||||
@@ -14,11 +16,13 @@ all: build-all-frontends start-backend
|
|||||||
|
|
||||||
build-frontend:
|
build-frontend:
|
||||||
@echo "Building default 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:
|
build-frontend-classic:
|
||||||
@echo "Building classic frontend..."
|
@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
|
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)
|
@docker compose -f $(DEV_COMPOSE_FILE) up -d --build $(DEV_BACKEND_SERVICE)
|
||||||
|
|
||||||
dev-web:
|
dev-web:
|
||||||
@echo "Starting frontend dev server..."
|
@echo "Starting both frontend dev servers..."
|
||||||
@cd $(FRONTEND_DIR) && bun install && bun run dev
|
@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:
|
dev-web-classic:
|
||||||
@echo "Starting classic frontend dev server..."
|
@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
|
dev: dev-api dev-web
|
||||||
|
|
||||||
|
|||||||
@@ -102,14 +102,10 @@ func Distribute() func(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
|
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
|
||||||
|
affinityUsable := false
|
||||||
preferred, err := model.CacheGetChannel(preferredChannelID)
|
preferred, err := model.CacheGetChannel(preferredChannelID)
|
||||||
if err == nil && preferred != nil {
|
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
|
||||||
if preferred.Status != common.ChannelStatusEnabled {
|
if usingGroup == "auto" {
|
||||||
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
|
|
||||||
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorAffinityChannelDisabled))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else if usingGroup == "auto" {
|
|
||||||
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
|
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
|
||||||
autoGroups := service.GetUserAutoGroup(userGroup)
|
autoGroups := service.GetUserAutoGroup(userGroup)
|
||||||
for _, g := range autoGroups {
|
for _, g := range autoGroups {
|
||||||
@@ -117,6 +113,7 @@ func Distribute() func(c *gin.Context) {
|
|||||||
selectGroup = g
|
selectGroup = g
|
||||||
common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
|
common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
|
||||||
channel = preferred
|
channel = preferred
|
||||||
|
affinityUsable = true
|
||||||
service.MarkChannelAffinityUsed(c, g, preferred.Id)
|
service.MarkChannelAffinityUsed(c, g, preferred.Id)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -124,9 +121,13 @@ func Distribute() func(c *gin.Context) {
|
|||||||
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
|
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
|
||||||
channel = preferred
|
channel = preferred
|
||||||
selectGroup = usingGroup
|
selectGroup = usingGroup
|
||||||
|
affinityUsable = true
|
||||||
service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
|
service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !affinityUsable && !service.ShouldKeepChannelAffinityOnChannelDisabled() {
|
||||||
|
service.ClearCurrentChannelAffinityCache(c)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if channel == nil {
|
if channel == nil {
|
||||||
@@ -298,6 +299,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
|||||||
} else if c.Request.Method == http.MethodGet {
|
} else if c.Request.Method == http.MethodGet {
|
||||||
relayMode = relayconstant.RelayModeVideoFetchByID
|
relayMode = relayconstant.RelayModeVideoFetchByID
|
||||||
shouldSelectChannel = false
|
shouldSelectChannel = false
|
||||||
|
modelRequest.Model = getTaskOriginModelName(c)
|
||||||
}
|
}
|
||||||
c.Set("relay_mode", relayMode)
|
c.Set("relay_mode", relayMode)
|
||||||
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
|
} 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 {
|
} else if c.Request.Method == http.MethodGet {
|
||||||
relayMode = relayconstant.RelayModeVideoFetchByID
|
relayMode = relayconstant.RelayModeVideoFetchByID
|
||||||
shouldSelectChannel = false
|
shouldSelectChannel = false
|
||||||
|
modelRequest.Model = getTaskOriginModelName(c)
|
||||||
}
|
}
|
||||||
if _, ok := c.Get("relay_mode"); !ok {
|
if _, ok := c.Get("relay_mode"); !ok {
|
||||||
c.Set("relay_mode", relayMode)
|
c.Set("relay_mode", relayMode)
|
||||||
@@ -396,6 +399,31 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
|||||||
return &modelRequest, shouldSelectChannel, nil
|
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 {
|
func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError {
|
||||||
c.Set("original_model", modelName) // for retry
|
c.Set("original_model", modelName) // for retry
|
||||||
if channel == nil {
|
if channel == nil {
|
||||||
|
|||||||
@@ -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
@@ -32,9 +32,9 @@ func applyExplicitLogTextFilter(tx *gorm.DB, column string, value string) (*gorm
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Log struct {
|
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"`
|
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"`
|
Type int `json:"type" gorm:"index:idx_created_at_type"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,6 +397,7 @@ func ensureSubscriptionPlanTableSQLite() error {
|
|||||||
` + "`custom_seconds`" + ` bigint NOT NULL DEFAULT 0,
|
` + "`custom_seconds`" + ` bigint NOT NULL DEFAULT 0,
|
||||||
` + "`enabled`" + ` numeric DEFAULT 1,
|
` + "`enabled`" + ` numeric DEFAULT 1,
|
||||||
` + "`sort_order`" + ` integer DEFAULT 0,
|
` + "`sort_order`" + ` integer DEFAULT 0,
|
||||||
|
` + "`allow_balance_pay`" + ` numeric DEFAULT 1,
|
||||||
` + "`stripe_price_id`" + ` varchar(128) DEFAULT '',
|
` + "`stripe_price_id`" + ` varchar(128) DEFAULT '',
|
||||||
` + "`creem_product_id`" + ` varchar(128) DEFAULT '',
|
` + "`creem_product_id`" + ` varchar(128) DEFAULT '',
|
||||||
` + "`waffo_pancake_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: "custom_seconds", DDL: "`custom_seconds` bigint NOT NULL DEFAULT 0"},
|
||||||
{Name: "enabled", DDL: "`enabled` numeric DEFAULT 1"},
|
{Name: "enabled", DDL: "`enabled` numeric DEFAULT 1"},
|
||||||
{Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"},
|
{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: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"},
|
||||||
{Name: "creem_product_id", DDL: "`creem_product_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 ''"},
|
{Name: "waffo_pancake_product_id", DDL: "`waffo_pancake_product_id` varchar(128) DEFAULT ''"},
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ type SubscriptionPlan struct {
|
|||||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||||
SortOrder int `json:"sort_order" gorm:"type:int;default:0"`
|
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:''"`
|
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
|
||||||
CreemProductId string `json:"creem_product_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:''"`
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *SubscriptionPlan) NormalizeDefaults() {
|
||||||
|
if p.AllowBalancePay == nil {
|
||||||
|
p.AllowBalancePay = common.GetPointer(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Subscription order (payment -> webhook -> create UserSubscription)
|
// Subscription order (payment -> webhook -> create UserSubscription)
|
||||||
type SubscriptionOrder struct {
|
type SubscriptionOrder struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
@@ -360,6 +368,7 @@ func getSubscriptionPlanByIdTx(tx *gorm.DB, id int) (*SubscriptionPlan, error) {
|
|||||||
key := subscriptionPlanCacheKey(id)
|
key := subscriptionPlanCacheKey(id)
|
||||||
if key != "" {
|
if key != "" {
|
||||||
if cached, found, err := getSubscriptionPlanCache().Get(key); err == nil && found {
|
if cached, found, err := getSubscriptionPlanCache().Get(key); err == nil && found {
|
||||||
|
cached.NormalizeDefaults()
|
||||||
return &cached, nil
|
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 {
|
if err := query.Where("id = ?", id).First(&plan).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
plan.NormalizeDefaults()
|
||||||
_ = getSubscriptionPlanCache().SetWithTTL(key, plan, subscriptionPlanCacheTTL())
|
_ = getSubscriptionPlanCache().SetWithTTL(key, plan, subscriptionPlanCacheTTL())
|
||||||
return &plan, nil
|
return &plan, nil
|
||||||
}
|
}
|
||||||
@@ -701,6 +711,9 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error {
|
|||||||
if plan.PriceAmount < 0 {
|
if plan.PriceAmount < 0 {
|
||||||
return errors.New("套餐价格不能为负数")
|
return errors.New("套餐价格不能为负数")
|
||||||
}
|
}
|
||||||
|
if plan.AllowBalancePay != nil && !*plan.AllowBalancePay {
|
||||||
|
return errors.New("该套餐不允许使用余额兑换")
|
||||||
|
}
|
||||||
|
|
||||||
requiredQuota, err := calcSubscriptionBalanceQuota(plan.PriceAmount)
|
requiredQuota, err := calcSubscriptionBalanceQuota(plan.PriceAmount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0",
|
||||||
"claude-opus-4-6": "anthropic.claude-opus-4-6-v1",
|
"claude-opus-4-6": "anthropic.claude-opus-4-6-v1",
|
||||||
"claude-opus-4-7": "anthropic.claude-opus-4-7",
|
"claude-opus-4-7": "anthropic.claude-opus-4-7",
|
||||||
|
"claude-opus-4-8": "anthropic.claude-opus-4-8",
|
||||||
// Nova models
|
// Nova models
|
||||||
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
|
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
|
||||||
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
|
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
|
||||||
@@ -97,6 +98,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
|
|||||||
"ap": true,
|
"ap": true,
|
||||||
"eu": true,
|
"eu": true,
|
||||||
},
|
},
|
||||||
|
"anthropic.claude-opus-4-8": {
|
||||||
|
"us": true,
|
||||||
|
"ap": true,
|
||||||
|
"eu": true,
|
||||||
|
},
|
||||||
"anthropic.claude-haiku-4-5-20251001-v1:0": {
|
"anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||||
"us": true,
|
"us": true,
|
||||||
"ap": true,
|
"ap": true,
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ var ModelList = []string{
|
|||||||
"claude-opus-4-7-medium",
|
"claude-opus-4-7-medium",
|
||||||
"claude-opus-4-7-low",
|
"claude-opus-4-7-low",
|
||||||
"claude-opus-4-7-thinking",
|
"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"
|
var ChannelName = "claude"
|
||||||
|
|||||||
@@ -154,14 +154,17 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
|
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.Model = baseModel
|
||||||
claudeRequest.Thinking = &dto.Thinking{
|
claudeRequest.Thinking = &dto.Thinking{
|
||||||
Type: "adaptive",
|
Type: "adaptive",
|
||||||
}
|
}
|
||||||
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
||||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
|
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
|
||||||
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
|
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.
|
// and defaults display to "omitted"; restore the 4.6 visible summary.
|
||||||
claudeRequest.Thinking.Display = "summarized"
|
claudeRequest.Thinking.Display = "summarized"
|
||||||
claudeRequest.Temperature = nil
|
claudeRequest.Temperature = nil
|
||||||
@@ -175,8 +178,9 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
|||||||
strings.HasSuffix(textRequest.Model, "-thinking") {
|
strings.HasSuffix(textRequest.Model, "-thinking") {
|
||||||
|
|
||||||
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
|
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
|
||||||
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") {
|
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") ||
|
||||||
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
|
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.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
|
||||||
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
||||||
claudeRequest.Temperature = nil
|
claudeRequest.Temperature = nil
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func commonPointer[T any](value T) *T {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) {
|
func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) {
|
||||||
claudeInfo := &ClaudeResponseInfo{
|
claudeInfo := &ClaudeResponseInfo{
|
||||||
Usage: &dto.Usage{},
|
Usage: &dto.Usage{},
|
||||||
@@ -310,6 +314,58 @@ func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T)
|
|||||||
require.Equal(t, "see attachment", *content[0].Text)
|
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) {
|
func TestRequestOpenAI2ClaudeMessage_SupportsPDFFileContent(t *testing.T) {
|
||||||
request := dto.GeneralOpenAIRequest{
|
request := dto.GeneralOpenAIRequest{
|
||||||
Model: "claude-3-5-sonnet",
|
Model: "claude-3-5-sonnet",
|
||||||
|
|||||||
@@ -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) {
|
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)
|
scanner.Split(bufio.ScanLines)
|
||||||
|
|
||||||
helper.SetEventStreamHeaders(c)
|
helper.SetEventStreamHeaders(c)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package cohere
|
package cohere
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -86,7 +85,7 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
|||||||
createdTime := common.GetTimestamp()
|
createdTime := common.GetTimestamp()
|
||||||
usage := &dto.Usage{}
|
usage := &dto.Usage{}
|
||||||
responseText := ""
|
responseText := ""
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := helper.NewStreamScanner(resp.Body)
|
||||||
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||||
if atEOF && len(data) == 0 {
|
if atEOF && len(data) == 0 {
|
||||||
return 0, nil, nil
|
return 0, nil, nil
|
||||||
@@ -106,6 +105,9 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
|||||||
data := scanner.Text()
|
data := scanner.Text()
|
||||||
dataChan <- data
|
dataChan <- data
|
||||||
}
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
common.SysLog("error reading stream: " + err.Error())
|
||||||
|
}
|
||||||
stopChan <- true
|
stopChan <- true
|
||||||
}()
|
}()
|
||||||
helper.SetEventStreamHeaders(c)
|
helper.SetEventStreamHeaders(c)
|
||||||
|
|||||||
@@ -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) {
|
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)
|
scanner.Split(bufio.ScanLines)
|
||||||
helper.SetEventStreamHeaders(c)
|
helper.SetEventStreamHeaders(c)
|
||||||
id := helper.GetResponseID(c)
|
id := helper.GetResponseID(c)
|
||||||
|
|||||||
@@ -159,9 +159,14 @@ func requestOpenAI2Dify(c *gin.Context, info *relaycommon.RelayInfo, request dto
|
|||||||
media := mediaContent.GetImageMedia()
|
media := mediaContent.GetImageMedia()
|
||||||
var file *DifyFile
|
var file *DifyFile
|
||||||
if media.IsRemoteImage() {
|
if media.IsRemoteImage() {
|
||||||
file.Type = media.MimeType
|
// 修复 #2083: 远程图片分支此前未初始化 file,
|
||||||
file.TransferMode = "remote_url"
|
// 导致 file.Type = ... 触发 nil pointer dereference
|
||||||
file.URL = media.Url
|
// 而 panic(500: "invalid memory address or nil pointer dereference")。
|
||||||
|
file = &DifyFile{
|
||||||
|
Type: media.MimeType,
|
||||||
|
TransferMode: "remote_url",
|
||||||
|
URL: media.Url,
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
file = uploadDifyFile(c, info, difyReq.User, mediaContent)
|
file = uploadDifyFile(c, info, difyReq.User, mediaContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package ollama
|
package ollama
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -12,6 +11,7 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
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/service"
|
||||||
"github.com/QuantumNous/new-api/types"
|
"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
|
successful := false
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package ollama
|
package ollama
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -70,7 +69,7 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
|||||||
defer service.CloseResponseBodyGracefully(resp)
|
defer service.CloseResponseBodyGracefully(resp)
|
||||||
|
|
||||||
helper.SetEventStreamHeaders(c)
|
helper.SetEventStreamHeaders(c)
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := helper.NewStreamScanner(resp.Body)
|
||||||
usage := &dto.Usage{}
|
usage := &dto.Usage{}
|
||||||
var model = info.UpstreamModelName
|
var model = info.UpstreamModelName
|
||||||
var responseId = common.GetUUID()
|
var responseId = common.GetUUID()
|
||||||
|
|||||||
@@ -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 {
|
if lo.FromPtrOr(request.MaxCompletionTokens, uint(0)) == 0 && lo.FromPtrOr(request.MaxTokens, uint(0)) != 0 {
|
||||||
request.MaxCompletionTokens = request.MaxTokens
|
request.MaxCompletionTokens = request.MaxTokens
|
||||||
request.MaxTokens = nil
|
request.MaxTokens = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(info.UpstreamModelName, "o") {
|
if isOModel {
|
||||||
request.Temperature = nil
|
request.Temperature = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// gpt-5系列模型适配 归零不再支持的参数
|
// gpt-5系列模型适配 归零不再支持的参数
|
||||||
if strings.HasPrefix(info.UpstreamModelName, "gpt-5") {
|
if isGPT5Model {
|
||||||
request.Temperature = nil
|
request.Temperature = nil
|
||||||
request.TopP = nil
|
request.TopP = nil
|
||||||
request.LogProbs = nil
|
request.LogProbs = nil
|
||||||
|
|||||||
@@ -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) {
|
func tencentStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||||
var responseText string
|
var responseText string
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := helper.NewStreamScanner(resp.Body)
|
||||||
scanner.Split(bufio.ScanLines)
|
scanner.Split(bufio.ScanLines)
|
||||||
|
|
||||||
helper.SetEventStreamHeaders(c)
|
helper.SetEventStreamHeaders(c)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ var claudeModelMap = map[string]string{
|
|||||||
"claude-opus-4-5-20251101": "claude-opus-4-5@20251101",
|
"claude-opus-4-5-20251101": "claude-opus-4-5@20251101",
|
||||||
"claude-opus-4-6": "claude-opus-4-6",
|
"claude-opus-4-6": "claude-opus-4-6",
|
||||||
"claude-opus-4-7": "claude-opus-4-7",
|
"claude-opus-4-7": "claude-opus-4-7",
|
||||||
|
"claude-opus-4-8": "claude-opus-4-8",
|
||||||
}
|
}
|
||||||
|
|
||||||
const anthropicVersion = "vertex-2023-10-16"
|
const anthropicVersion = "vertex-2023-10-16"
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ func streamMetaResponseZhipu2OpenAI(zhipuResponse *ZhipuStreamMetaResponse) (*dt
|
|||||||
|
|
||||||
func zhipuStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
func zhipuStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||||
var usage *dto.Usage
|
var usage *dto.Usage
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := helper.NewStreamScanner(resp.Body)
|
||||||
scanner.Split(bufio.ScanLines)
|
scanner.Split(bufio.ScanLines)
|
||||||
dataChan := make(chan string)
|
dataChan := make(chan string)
|
||||||
metaChan := 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
|
stopChan <- true
|
||||||
}()
|
}()
|
||||||
helper.SetEventStreamHeaders(c)
|
helper.SetEventStreamHeaders(c)
|
||||||
|
|||||||
+10
-5
@@ -53,14 +53,17 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
|||||||
}
|
}
|
||||||
|
|
||||||
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" &&
|
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.Model = baseModel
|
||||||
request.Thinking = &dto.Thinking{
|
request.Thinking = &dto.Thinking{
|
||||||
Type: "adaptive",
|
Type: "adaptive",
|
||||||
}
|
}
|
||||||
request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
||||||
if strings.HasPrefix(request.Model, "claude-opus-4-7") {
|
if strings.HasPrefix(request.Model, "claude-opus-4-7") ||
|
||||||
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
|
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.
|
// and defaults display to "omitted"; restore the 4.6 visible summary.
|
||||||
request.Thinking.Display = "summarized"
|
request.Thinking.Display = "summarized"
|
||||||
request.Temperature = nil
|
request.Temperature = nil
|
||||||
@@ -74,8 +77,9 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
|||||||
strings.HasSuffix(request.Model, "-thinking") {
|
strings.HasSuffix(request.Model, "-thinking") {
|
||||||
if request.Thinking == nil {
|
if request.Thinking == nil {
|
||||||
baseModel := strings.TrimSuffix(request.Model, "-thinking")
|
baseModel := strings.TrimSuffix(request.Model, "-thinking")
|
||||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
|
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
|
||||||
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
|
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.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
|
||||||
request.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
request.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
||||||
request.Temperature = nil
|
request.Temperature = nil
|
||||||
@@ -151,6 +155,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
|
return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
|
||||||
}
|
}
|
||||||
|
info.UpstreamRequestBodySize = storage.Size()
|
||||||
requestBody = common.ReaderOnly(storage)
|
requestBody = common.ReaderOnly(storage)
|
||||||
} else {
|
} else {
|
||||||
convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, request)
|
convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, request)
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ func getScannerBufferSize() int {
|
|||||||
return DefaultMaxScannerBufferSize
|
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)) {
|
func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, dataHandler func(data string, sr *StreamResult)) {
|
||||||
|
|
||||||
if resp == nil || dataHandler == nil {
|
if resp == nil || dataHandler == nil {
|
||||||
@@ -54,7 +60,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞
|
stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞
|
||||||
scanner = bufio.NewScanner(resp.Body)
|
scanner = NewStreamScanner(resp.Body)
|
||||||
ticker = time.NewTicker(streamingTimeout)
|
ticker = time.NewTicker(streamingTimeout)
|
||||||
pingTicker *time.Ticker
|
pingTicker *time.Ticker
|
||||||
writeMutex sync.Mutex // Mutex to protect concurrent writes
|
writeMutex sync.Mutex // Mutex to protect concurrent writes
|
||||||
@@ -104,7 +110,6 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
|||||||
close(stopChan)
|
close(stopChan)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
scanner.Buffer(make([]byte, InitialScannerBufferSize), getScannerBufferSize())
|
|
||||||
scanner.Split(bufio.ScanLines)
|
scanner.Split(bufio.ScanLines)
|
||||||
SetEventStreamHeaders(c)
|
SetEventStreamHeaders(c)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package helper
|
package helper
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -81,6 +82,22 @@ func TestStreamScannerHandler_NilInputs(t *testing.T) {
|
|||||||
StreamScannerHandler(c, &http.Response{Body: io.NopCloser(strings.NewReader(""))}, info, nil)
|
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) {
|
func TestStreamScannerHandler_EmptyBody(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+17
-16
@@ -17,9 +17,10 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
|
apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||||
apiRouter.Use(middleware.BodyStorageCleanup()) // 清理请求体存储
|
apiRouter.Use(middleware.BodyStorageCleanup()) // 清理请求体存储
|
||||||
apiRouter.Use(middleware.GlobalAPIRateLimit())
|
apiRouter.Use(middleware.GlobalAPIRateLimit())
|
||||||
|
anonymousRequestBodyLimit := middleware.AnonymousRequestBodyLimit()
|
||||||
{
|
{
|
||||||
apiRouter.GET("/setup", controller.GetSetup)
|
apiRouter.GET("/setup", controller.GetSetup)
|
||||||
apiRouter.POST("/setup", controller.PostSetup)
|
apiRouter.POST("/setup", anonymousRequestBodyLimit, controller.PostSetup)
|
||||||
apiRouter.GET("/status", controller.GetStatus)
|
apiRouter.GET("/status", controller.GetStatus)
|
||||||
apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus)
|
apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus)
|
||||||
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
|
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("/rankings", middleware.HeaderNavModuleAuth("rankings"), controller.GetRankings)
|
||||||
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
|
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
|
||||||
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
|
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
|
// OAuth routes - specific routes must come before :provider wildcard
|
||||||
apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode)
|
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
|
// Non-standard OAuth (WeChat, Telegram) - keep original routes
|
||||||
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
|
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/login", middleware.CriticalRateLimit(), controller.TelegramLogin)
|
||||||
apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind)
|
apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind)
|
||||||
// Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route
|
// Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route
|
||||||
apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), controller.HandleOAuth)
|
apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), controller.HandleOAuth)
|
||||||
apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)
|
apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)
|
||||||
|
|
||||||
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
|
apiRouter.POST("/stripe/webhook", anonymousRequestBodyLimit, controller.StripeWebhook)
|
||||||
apiRouter.POST("/creem/webhook", controller.CreemWebhook)
|
apiRouter.POST("/creem/webhook", anonymousRequestBodyLimit, controller.CreemWebhook)
|
||||||
apiRouter.POST("/waffo/webhook", controller.WaffoWebhook)
|
apiRouter.POST("/waffo/webhook", anonymousRequestBodyLimit, controller.WaffoWebhook)
|
||||||
// :env separates test vs prod URLs so the operator can register each
|
// :env separates test vs prod URLs so the operator can register each
|
||||||
// in Pancake's matching webhook slot; handler enforces env match.
|
// 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
|
// Universal secure verification routes
|
||||||
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
|
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
|
||||||
|
|
||||||
userRoute := apiRouter.Group("/user")
|
userRoute := apiRouter.Group("/user")
|
||||||
{
|
{
|
||||||
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
|
userRoute.POST("/register", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Register)
|
||||||
userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login)
|
userRoute.POST("/login", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Login)
|
||||||
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin)
|
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.Verify2FALogin)
|
||||||
userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), controller.PasskeyLoginBegin)
|
userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.PasskeyLoginBegin)
|
||||||
userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), controller.PasskeyLoginFinish)
|
userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.PasskeyLoginFinish)
|
||||||
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
|
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
|
||||||
userRoute.GET("/logout", controller.Logout)
|
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("/epay/notify", controller.EpayNotify)
|
||||||
userRoute.GET("/groups", controller.GetUserGroups)
|
userRoute.GET("/groups", controller.GetUserGroups)
|
||||||
|
|
||||||
@@ -176,10 +177,10 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscription payment callbacks (no auth)
|
// 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/notify", controller.SubscriptionEpayNotify)
|
||||||
apiRouter.GET("/subscription/epay/return", controller.SubscriptionEpayReturn)
|
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 := apiRouter.Group("/option")
|
||||||
optionRoute.Use(middleware.RootAuth())
|
optionRoute.Use(middleware.RootAuth())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -641,6 +641,38 @@ func ShouldSkipRetryAfterChannelAffinityFailure(c *gin.Context) bool {
|
|||||||
return meta.SkipRetry
|
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) {
|
func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int) {
|
||||||
if c == nil || channelID <= 0 {
|
if c == nil || channelID <= 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -236,6 +236,33 @@ func TestGetPreferredChannelByAffinity_RequestHeaderKeySource(t *testing.T) {
|
|||||||
require.Equal(t, buildChannelAffinityKeyHint(affinityValue), meta.KeyHint)
|
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) {
|
func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func InitHttpClient() {
|
|||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
MaxIdleConns: common.RelayMaxIdleConns,
|
MaxIdleConns: common.RelayMaxIdleConns,
|
||||||
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
|
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
|
||||||
|
IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second,
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
Proxy: http.ProxyFromEnvironment, // Support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars
|
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{
|
transport := &http.Transport{
|
||||||
MaxIdleConns: common.RelayMaxIdleConns,
|
MaxIdleConns: common.RelayMaxIdleConns,
|
||||||
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
|
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
|
||||||
|
IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second,
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
Proxy: http.ProxyURL(parsedURL),
|
Proxy: http.ProxyURL(parsedURL),
|
||||||
}
|
}
|
||||||
@@ -147,6 +149,7 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
|
|||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
MaxIdleConns: common.RelayMaxIdleConns,
|
MaxIdleConns: common.RelayMaxIdleConns,
|
||||||
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
|
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
|
||||||
|
IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second,
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
return dialer.Dial(network, addr)
|
return dialer.Dial(network, addr)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type ChannelAffinityRule struct {
|
|||||||
type ChannelAffinitySetting struct {
|
type ChannelAffinitySetting struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
SwitchOnSuccess bool `json:"switch_on_success"`
|
SwitchOnSuccess bool `json:"switch_on_success"`
|
||||||
|
KeepOnChannelDisabled bool `json:"keep_on_channel_disabled"`
|
||||||
MaxEntries int `json:"max_entries"`
|
MaxEntries int `json:"max_entries"`
|
||||||
DefaultTTLSeconds int `json:"default_ttl_seconds"`
|
DefaultTTLSeconds int `json:"default_ttl_seconds"`
|
||||||
Rules []ChannelAffinityRule `json:"rules"`
|
Rules []ChannelAffinityRule `json:"rules"`
|
||||||
@@ -76,6 +77,7 @@ func buildPassHeaderTemplate(headers []string) map[string]interface{} {
|
|||||||
var channelAffinitySetting = ChannelAffinitySetting{
|
var channelAffinitySetting = ChannelAffinitySetting{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
SwitchOnSuccess: true,
|
SwitchOnSuccess: true,
|
||||||
|
KeepOnChannelDisabled: false,
|
||||||
MaxEntries: 100_000,
|
MaxEntries: 100_000,
|
||||||
DefaultTTLSeconds: 3600,
|
DefaultTTLSeconds: 3600,
|
||||||
Rules: []ChannelAffinityRule{
|
Rules: []ChannelAffinityRule{
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ var defaultCacheRatio = map[string]float64{
|
|||||||
"claude-opus-4-7-high": 0.1,
|
"claude-opus-4-7-high": 0.1,
|
||||||
"claude-opus-4-7-medium": 0.1,
|
"claude-opus-4-7-medium": 0.1,
|
||||||
"claude-opus-4-7-low": 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{
|
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-high": 1.25,
|
||||||
"claude-opus-4-7-medium": 1.25,
|
"claude-opus-4-7-medium": 1.25,
|
||||||
"claude-opus-4-7-low": 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{}
|
//var defaultCreateCacheRatio = map[string]float64{}
|
||||||
|
|||||||
@@ -152,6 +152,12 @@ var defaultModelRatio = map[string]float64{
|
|||||||
"claude-opus-4-7-high": 2.5,
|
"claude-opus-4-7-high": 2.5,
|
||||||
"claude-opus-4-7-medium": 2.5,
|
"claude-opus-4-7-medium": 2.5,
|
||||||
"claude-opus-4-7-low": 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-3-opus-20240229": 7.5, // $15 / 1M tokens
|
||||||
"claude-opus-4-20250514": 7.5,
|
"claude-opus-4-20250514": 7.5,
|
||||||
"claude-opus-4-1-20250805": 7.5,
|
"claude-opus-4-1-20250805": 7.5,
|
||||||
|
|||||||
Vendored
+1136
-371
File diff suppressed because it is too large
Load Diff
Vendored
-2379
File diff suppressed because it is too large
Load Diff
Vendored
-1
@@ -24,6 +24,5 @@
|
|||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/index.jsx"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Vendored
+21
-20
@@ -4,30 +4,32 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@douyinfe/semi-illustrations": "^2.69.1",
|
||||||
"@douyinfe/semi-icons": "^2.63.1",
|
"@douyinfe/semi-icons": "^2.63.1",
|
||||||
"@douyinfe/semi-ui": "^2.69.1",
|
"@douyinfe/semi-ui": "^2.69.1",
|
||||||
"@lobehub/icons": "^2.0.0",
|
"@lobehub/icons": "catalog:",
|
||||||
"@visactor/react-vchart": "~1.8.8",
|
"@visactor/react-vchart": "~1.8.8",
|
||||||
"@visactor/vchart": "~1.8.8",
|
"@visactor/vchart": "~1.8.8",
|
||||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||||
"axios": "1.15.2",
|
"axios": "catalog:",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "catalog:",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "catalog:",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"i18next": "^23.16.8",
|
"i18next": "^23.16.8",
|
||||||
"i18next-browser-languagedetector": "^7.2.0",
|
"i18next-browser-languagedetector": "^7.2.0",
|
||||||
"katex": "^0.16.22",
|
"katex": "^0.16.22",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"marked": "^4.1.1",
|
"marked": "^4.1.1",
|
||||||
"mermaid": "^11.6.0",
|
"mermaid": "^11.6.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "catalog:",
|
||||||
"react": "^18.2.0",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^19.2.6",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-fireworks": "^1.0.4",
|
"react-fireworks": "^1.0.4",
|
||||||
"react-i18next": "^13.0.0",
|
"react-i18next": "^13.0.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "catalog:",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "catalog:",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"react-telegram-login": "^1.1.2",
|
"react-telegram-login": "^1.1.2",
|
||||||
"react-toastify": "^9.0.8",
|
"react-toastify": "^9.0.8",
|
||||||
@@ -35,20 +37,20 @@
|
|||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "catalog:",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"sse.js": "^2.6.0",
|
"sse.js": "catalog:",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
"use-debounce": "^10.0.4"
|
"use-debounce": "^10.0.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "rsbuild dev",
|
||||||
"build": "vite build",
|
"build": "rsbuild build",
|
||||||
"lint": "prettier . --check",
|
"lint": "prettier . --check",
|
||||||
"lint:fix": "prettier . --write",
|
"lint:fix": "prettier . --write",
|
||||||
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
|
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
|
||||||
"eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache",
|
"eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache",
|
||||||
"preview": "vite preview",
|
"preview": "rsbuild preview",
|
||||||
"i18n:extract": "bunx i18next-cli extract",
|
"i18n:extract": "bunx i18next-cli extract",
|
||||||
"i18n:status": "bunx i18next-cli status",
|
"i18n:status": "bunx i18next-cli status",
|
||||||
"i18n:sync": "bunx i18next-cli sync",
|
"i18n:sync": "bunx i18next-cli sync",
|
||||||
@@ -73,20 +75,19 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"@so1ve/prettier-config": "^3.1.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"code-inspector-plugin": "^1.3.3",
|
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-plugin-header": "^3.1.1",
|
"eslint-plugin-header": "^3.1.1",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"i18next-cli": "^1.10.3",
|
"i18next-cli": "^1.10.3",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"prettier": "^3.0.0",
|
"prop-types": "^15.8.1",
|
||||||
|
"prettier": "catalog:",
|
||||||
"tailwindcss": "^3",
|
"tailwindcss": "^3",
|
||||||
"typescript": "4.4.2",
|
"typescript": "4.4.2"
|
||||||
"vite": "^5.2.0"
|
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
|
|||||||
Vendored
+106
@@ -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
@@ -947,7 +947,7 @@ const LoginForm = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className='blur-ball blur-ball-indigo'
|
className='blur-ball blur-ball-indigo'
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ const PasswordResetConfirm = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className='blur-ball blur-ball-indigo'
|
className='blur-ball blur-ball-indigo'
|
||||||
|
|||||||
+1
-1
@@ -104,7 +104,7 @@ const PasswordResetForm = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className='blur-ball blur-ball-indigo'
|
className='blur-ball blur-ball-indigo'
|
||||||
|
|||||||
+1
-1
@@ -770,7 +770,7 @@ const RegisterForm = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className='blur-ball blur-ball-indigo'
|
className='blur-ball blur-ball-indigo'
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
|||||||
// 显示加载状态
|
// 显示加载状态
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className='flex justify-center items-center min-h-screen'>
|
<div className='classic-page-fill flex justify-center items-center'>
|
||||||
<Spin size='large' />
|
<Spin size='large' />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -150,7 +150,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
|||||||
// 如果没有内容,显示空状态
|
// 如果没有内容,显示空状态
|
||||||
if (!content || content.trim() === '') {
|
if (!content || content.trim() === '') {
|
||||||
return (
|
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
|
<Empty
|
||||||
title={t('管理员未设置' + title + '内容')}
|
title={t('管理员未设置' + title + '内容')}
|
||||||
image={
|
image={
|
||||||
@@ -168,7 +168,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
|||||||
// 如果是 URL,显示链接卡片
|
// 如果是 URL,显示链接卡片
|
||||||
if (isUrl(content)) {
|
if (isUrl(content)) {
|
||||||
return (
|
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'>
|
<Card className='max-w-md w-full'>
|
||||||
<div className='text-center'>
|
<div className='text-center'>
|
||||||
<Title heading={4} className='mb-4'>
|
<Title heading={4} className='mb-4'>
|
||||||
@@ -196,7 +196,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
|||||||
// 如果是 HTML 内容,直接渲染
|
// 如果是 HTML 内容,直接渲染
|
||||||
if (isHtmlContent(content)) {
|
if (isHtmlContent(content)) {
|
||||||
return (
|
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='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'>
|
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||||
<Title heading={2} className='text-center mb-8'>
|
<Title heading={2} className='text-center mb-8'>
|
||||||
@@ -214,7 +214,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
|||||||
|
|
||||||
// 其他内容统一使用 Markdown 渲染器
|
// 其他内容统一使用 Markdown 渲染器
|
||||||
return (
|
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='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'>
|
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||||
<Title heading={2} className='text-center mb-8'>
|
<Title heading={2} className='text-center mb-8'>
|
||||||
|
|||||||
+10
-5
@@ -71,6 +71,7 @@ const PageLayout = () => {
|
|||||||
|
|
||||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
const isConsoleRoute = location.pathname.startsWith('/console');
|
||||||
const showSider = isConsoleRoute && (!isMobile || drawerOpen);
|
const showSider = isConsoleRoute && (!isMobile || drawerOpen);
|
||||||
|
const isFixedLayout = isConsoleRoute || location.pathname === '/pricing';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile && drawerOpen && collapsed) {
|
if (isMobile && drawerOpen && collapsed) {
|
||||||
@@ -146,11 +147,11 @@ const PageLayout = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
className='app-layout'
|
className={`app-layout${isFixedLayout ? ' app-layout-fixed' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: isMobile ? 'visible' : 'hidden',
|
overflow: isFixedLayout && !isMobile ? 'hidden' : 'visible',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Header
|
<Header
|
||||||
@@ -171,9 +172,10 @@ const PageLayout = () => {
|
|||||||
</Header>
|
</Header>
|
||||||
<Layout
|
<Layout
|
||||||
style={{
|
style={{
|
||||||
overflow: isMobile ? 'visible' : 'auto',
|
overflow: isFixedLayout && !isMobile ? 'auto' : 'visible',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
flex: '1 1 auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showSider && (
|
{showSider && (
|
||||||
@@ -206,15 +208,18 @@ const PageLayout = () => {
|
|||||||
flex: '1 1 auto',
|
flex: '1 1 auto',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Content
|
<Content
|
||||||
|
className={isFixedLayout ? undefined : 'public-page-content'}
|
||||||
style={{
|
style={{
|
||||||
flex: '1 0 auto',
|
flex: isFixedLayout ? '1 0 auto' : '1 1 auto',
|
||||||
overflowY: isMobile ? 'visible' : 'hidden',
|
overflowY: isFixedLayout && !isMobile ? 'hidden' : 'visible',
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
padding: shouldInnerPadding ? (isMobile ? '5px' : '24px') : '0',
|
padding: shouldInnerPadding ? (isMobile ? '5px' : '24px') : '0',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
minHeight: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
|||||||
Vendored
+43
-9
@@ -17,12 +17,46 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './channel.constants';
|
export {
|
||||||
export * from './user.constants';
|
CHANNEL_OPTIONS,
|
||||||
export * from './toast.constants';
|
MODEL_FETCHABLE_CHANNEL_TYPES,
|
||||||
export * from './common.constant';
|
MODEL_TABLE_PAGE_SIZE,
|
||||||
export * from './dashboard.constants';
|
} from './channel.constants';
|
||||||
export * from './playground.constants';
|
export { userConstants } from './user.constants';
|
||||||
export * from './redemption.constants';
|
export { toastConstants } from './toast.constants';
|
||||||
export * from './channel-affinity-template.constants';
|
export {
|
||||||
export * from './billing.constants';
|
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';
|
||||||
|
|||||||
Vendored
+8
-22
@@ -94,7 +94,6 @@ import {
|
|||||||
SiGitlab,
|
SiGitlab,
|
||||||
SiGoogle,
|
SiGoogle,
|
||||||
SiKeycloak,
|
SiKeycloak,
|
||||||
SiLinkedin,
|
|
||||||
SiNextcloud,
|
SiNextcloud,
|
||||||
SiNotion,
|
SiNotion,
|
||||||
SiOkta,
|
SiOkta,
|
||||||
@@ -106,6 +105,7 @@ import {
|
|||||||
SiWechat,
|
SiWechat,
|
||||||
SiX,
|
SiX,
|
||||||
} from 'react-icons/si';
|
} from 'react-icons/si';
|
||||||
|
import { FaLinkedin } from 'react-icons/fa';
|
||||||
|
|
||||||
// 获取侧边栏Lucide图标组件
|
// 获取侧边栏Lucide图标组件
|
||||||
export function getLucideIcon(key, selected = false) {
|
export function getLucideIcon(key, selected = false) {
|
||||||
@@ -509,7 +509,7 @@ const oauthProviderIconMap = {
|
|||||||
google: SiGoogle,
|
google: SiGoogle,
|
||||||
discord: SiDiscord,
|
discord: SiDiscord,
|
||||||
facebook: SiFacebook,
|
facebook: SiFacebook,
|
||||||
linkedin: SiLinkedin,
|
linkedin: FaLinkedin,
|
||||||
x: SiX,
|
x: SiX,
|
||||||
twitter: SiX,
|
twitter: SiX,
|
||||||
slack: SiSlack,
|
slack: SiSlack,
|
||||||
@@ -1068,31 +1068,17 @@ export function getQuotaWithUnit(quota, digits = 6) {
|
|||||||
return (quota / quotaPerUnit).toFixed(digits);
|
return (quota / quotaPerUnit).toFixed(digits);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// amount 为系统内部的美元值
|
||||||
export function renderQuotaWithAmount(amount) {
|
export function renderQuotaWithAmount(amount) {
|
||||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
const { symbol, rate, type } = getCurrencyConfig();
|
||||||
if (quotaDisplayType === 'TOKENS') {
|
if (type === 'TOKENS') {
|
||||||
return renderNumber(renderUnitWithQuota(amount));
|
return renderNumber(renderUnitWithQuota(amount));
|
||||||
}
|
}
|
||||||
|
|
||||||
const numericAmount = Number(amount);
|
const numericAmount = Number(amount);
|
||||||
const formattedAmount = Number.isFinite(numericAmount)
|
if (!Number.isFinite(numericAmount)) {
|
||||||
? numericAmount.toFixed(2)
|
return symbol + amount;
|
||||||
: 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 + (numericAmount * rate).toFixed(2);
|
||||||
return symbol + formattedAmount;
|
|
||||||
}
|
|
||||||
return '$' + formattedAmount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Vendored
+1
-1
@@ -123,7 +123,7 @@ export function showError(error) {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
if (error.message) {
|
if (error.message) {
|
||||||
if (error.name === 'AxiosError') {
|
if (error.name === 'AxiosError') {
|
||||||
switch (error.response.status) {
|
switch (error.response?.status) {
|
||||||
case 401:
|
case 401:
|
||||||
// 清除用户状态
|
// 清除用户状态
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
|
|||||||
Vendored
+2
@@ -1197,6 +1197,7 @@
|
|||||||
"套餐的基本信息和定价": "Basic plan info and pricing",
|
"套餐的基本信息和定价": "Basic plan info and pricing",
|
||||||
"如:大带宽批量分析图片推荐": "e.g. Large bandwidth batch analysis of image recommendations",
|
"如:大带宽批量分析图片推荐": "e.g. Large bandwidth batch analysis of image recommendations",
|
||||||
"如:香港线路": "e.g. Hong Kong line",
|
"如:香港线路": "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.",
|
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "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.",
|
"如果你对接的是上游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",
|
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "If the user request contains a system prompt, this setting will be appended to the user's system prompt",
|
||||||
@@ -1579,6 +1580,7 @@
|
|||||||
"成功": "Success",
|
"成功": "Success",
|
||||||
"成功兑换额度:": "Successful redemption amount:",
|
"成功兑换额度:": "Successful redemption amount:",
|
||||||
"成功后切换亲和": "Switch Affinity on Success",
|
"成功后切换亲和": "Switch Affinity on Success",
|
||||||
|
"渠道禁用后保留亲和": "Keep Affinity When Channel Is Disabled",
|
||||||
"成功时自动启用通道": "Enable channel when successful",
|
"成功时自动启用通道": "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 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",
|
"我已阅读并同意": "I have read and agree to",
|
||||||
|
|||||||
Vendored
+2
@@ -1193,6 +1193,7 @@
|
|||||||
"套餐的基本信息和定价": "Informations de base et tarification du plan",
|
"套餐的基本信息和定价": "Informations de base et tarification du plan",
|
||||||
"如:大带宽批量分析图片推荐": "par exemple, Recommandations d'analyse d'images par lots à large bande passante",
|
"如:大带宽批量分析图片推荐": "par exemple, Recommandations d'analyse d'images par lots à large bande passante",
|
||||||
"如:香港线路": "par exemple, Ligne de Hong Kong",
|
"如:香港线路": "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.",
|
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "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.",
|
"如果你对接的是上游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",
|
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "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",
|
"成功": "Succès",
|
||||||
"成功兑换额度:": "Montant de l'échange réussi :",
|
"成功兑换额度:": "Montant de l'échange réussi :",
|
||||||
"成功后切换亲和": "Changer l'affinité en cas de succès",
|
"成功后切换亲和": "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",
|
"成功时自动启用通道": "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 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",
|
"我已阅读并同意": "J'ai lu et j'accepte",
|
||||||
|
|||||||
Vendored
+2
@@ -1180,6 +1180,7 @@
|
|||||||
"套餐的基本信息和定价": "プランの基本情報と価格",
|
"套餐的基本信息和定价": "プランの基本情報と価格",
|
||||||
"如:大带宽批量分析图片推荐": "例:広帯域での画像一括分析に推奨",
|
"如:大带宽批量分析图片推荐": "例:広帯域での画像一括分析に推奨",
|
||||||
"如:香港线路": "例:香港回線",
|
"如:香港线路": "例:香港回線",
|
||||||
|
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "有効にすると、アフィニティチャネルが無効化された、または現在のグループ/モデルで利用できなくなった場合でも、そのアフィニティエントリを保持します。無効にすると、エントリを削除して別のチャネルを選択します。",
|
||||||
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "アフィニティチャネルが失敗した場合、別のチャネルでリトライが成功すると、アフィニティが成功したチャネルに更新されます。",
|
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "アフィニティチャネルが失敗した場合、別のチャネルでリトライが成功すると、アフィニティが成功したチャネルに更新されます。",
|
||||||
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "New APIなどのリレープロジェクトに接続する場合は、OpenAIタイプを利用してください。設定内容を熟知している場合を除き、このタイプは利用しないでください",
|
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "New APIなどのリレープロジェクトに接続する場合は、OpenAIタイプを利用してください。設定内容を熟知している場合を除き、このタイプは利用しないでください",
|
||||||
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "ユーザーリクエストにシステムプロンプトが含まれている場合、この設定内容がユーザーのシステムプロンプトの前に追加されます",
|
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "ユーザーリクエストにシステムプロンプトが含まれている場合、この設定内容がユーザーのシステムプロンプトの前に追加されます",
|
||||||
@@ -1555,6 +1556,7 @@
|
|||||||
"成功": "成功",
|
"成功": "成功",
|
||||||
"成功兑换额度:": "引き換え額:",
|
"成功兑换额度:": "引き換え額:",
|
||||||
"成功后切换亲和": "成功時にアフィニティを切り替え",
|
"成功后切换亲和": "成功時にアフィニティを切り替え",
|
||||||
|
"渠道禁用后保留亲和": "チャネル無効時にアフィニティを保持",
|
||||||
"成功时自动启用通道": "成功時にチャネルを自動的に有効にする",
|
"成功时自动启用通道": "成功時にチャネルを自動的に有効にする",
|
||||||
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "2要素認証を無効にすると、すべての関連設定とバックアップコードが永久に削除され、この操作は元に戻すことができないことを理解しました",
|
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "2要素認証を無効にすると、すべての関連設定とバックアップコードが永久に削除され、この操作は元に戻すことができないことを理解しました",
|
||||||
"我已阅读并同意": "読んで同意します",
|
"我已阅读并同意": "読んで同意します",
|
||||||
|
|||||||
Vendored
+2
@@ -1201,6 +1201,7 @@
|
|||||||
"套餐的基本信息和定价": "Основная информация и цена плана",
|
"套餐的基本信息和定价": "Основная информация и цена плана",
|
||||||
"如:大带宽批量分析图片推荐": "Например: рекомендуется для пакетного анализа изображений с большой пропускной способностью",
|
"如:大带宽批量分析图片推荐": "Например: рекомендуется для пакетного анализа изображений с большой пропускной способностью",
|
||||||
"如:香港线路": "Например: Гонконгская линия",
|
"如:香港线路": "Например: Гонконгская линия",
|
||||||
|
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "Если включено, запись аффинити сохраняется, даже когда канал аффинити отключён или больше не подходит для текущей группы/модели. Если выключено, запись будет удалена и выбран другой канал.",
|
||||||
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "Если канал аффинити не сработал, после успешного повтора на другом канале аффинити будет обновлена на успешный канал.",
|
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "Если канал аффинити не сработал, после успешного повтора на другом канале аффинити будет обновлена на успешный канал.",
|
||||||
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "Если вы интегрируетесь с восходящими проектами пересылки, такими как One API или New API, используйте тип OpenAI, не используйте этот тип, если вы не знаете, что делаете.",
|
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "Если вы интегрируетесь с восходящими проектами пересылки, такими как One API или New API, используйте тип OpenAI, не используйте этот тип, если вы не знаете, что делаете.",
|
||||||
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "Если запрос пользователя содержит системный промпт, используйте эту настройку для добавления перед системным промптом пользователя",
|
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "Если запрос пользователя содержит системный промпт, используйте эту настройку для добавления перед системным промптом пользователя",
|
||||||
@@ -1602,6 +1603,7 @@
|
|||||||
"成功": "Успешно",
|
"成功": "Успешно",
|
||||||
"成功兑换额度:": "Успешно обменяно квота: ",
|
"成功兑换额度:": "Успешно обменяно квота: ",
|
||||||
"成功后切换亲和": "Переключить аффинити при успехе",
|
"成功后切换亲和": "Переключить аффинити при успехе",
|
||||||
|
"渠道禁用后保留亲和": "Сохранять аффинити при отключении канала",
|
||||||
"成功时自动启用通道": "Автоматически включать канал при успехе",
|
"成功时自动启用通道": "Автоматически включать канал при успехе",
|
||||||
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "Я понимаю, что отключение двухфакторной аутентификации приведет к постоянному удалению всех связанных настроек и резервных кодов, и эта операция не может быть отменена",
|
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "Я понимаю, что отключение двухфакторной аутентификации приведет к постоянному удалению всех связанных настроек и резервных кодов, и эта операция не может быть отменена",
|
||||||
"我已阅读并同意": "Я прочитал(а) и согласен(на)",
|
"我已阅读并同意": "Я прочитал(а) и согласен(на)",
|
||||||
|
|||||||
Vendored
+2
@@ -1181,6 +1181,7 @@
|
|||||||
"套餐的基本信息和定价": "Thông tin cơ bản và giá của gói",
|
"套餐的基本信息和定价": "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ụ: 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",
|
"如:香港线路": "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.",
|
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "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ì.",
|
"如果你对接的是上游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",
|
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "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",
|
"成功": "Thành công",
|
||||||
"成功兑换额度:": "Số tiền đổi thành công:",
|
"成功兑换额度:": "Số tiền đổi thành công:",
|
||||||
"成功后切换亲和": "Chuyển ưu ái khi 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",
|
"成功时自动启用通道": "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 đã 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",
|
"我已阅读并同意": "Tôi đã đọc và đồng ý với",
|
||||||
|
|||||||
+2
@@ -1170,6 +1170,7 @@
|
|||||||
"套餐的基本信息和定价": "套餐的基本信息和定价",
|
"套餐的基本信息和定价": "套餐的基本信息和定价",
|
||||||
"如:大带宽批量分析图片推荐": "如:大带宽批量分析图片推荐",
|
"如:大带宽批量分析图片推荐": "如:大带宽批量分析图片推荐",
|
||||||
"如:香港线路": "如:香港线路",
|
"如:香港线路": "如:香港线路",
|
||||||
|
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。",
|
||||||
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。",
|
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。",
|
||||||
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。",
|
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。",
|
||||||
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面",
|
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面",
|
||||||
@@ -1541,6 +1542,7 @@
|
|||||||
"成功": "成功",
|
"成功": "成功",
|
||||||
"成功兑换额度:": "成功兑换额度:",
|
"成功兑换额度:": "成功兑换额度:",
|
||||||
"成功后切换亲和": "成功后切换亲和",
|
"成功后切换亲和": "成功后切换亲和",
|
||||||
|
"渠道禁用后保留亲和": "渠道禁用后保留亲和",
|
||||||
"成功时自动启用通道": "成功时自动启用通道",
|
"成功时自动启用通道": "成功时自动启用通道",
|
||||||
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销",
|
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销",
|
||||||
"我已阅读并同意": "我已阅读并同意",
|
"我已阅读并同意": "我已阅读并同意",
|
||||||
|
|||||||
+2
@@ -1179,6 +1179,7 @@
|
|||||||
"套餐的基本信息和定价": "訂閱的基本資訊和定價",
|
"套餐的基本信息和定价": "訂閱的基本資訊和定價",
|
||||||
"如:大带宽批量分析图片推荐": "如:大頻寬批量分析圖片推薦",
|
"如:大带宽批量分析图片推荐": "如:大頻寬批量分析圖片推薦",
|
||||||
"如:香港线路": "如:香港線路",
|
"如:香港线路": "如:香港線路",
|
||||||
|
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "開啟後,親和到的渠道被停用,或不再適用於目前分組/模型時,仍保留這條親和;關閉時會刪除並重新選擇渠道。",
|
||||||
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "",
|
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "",
|
||||||
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "如果你對接的是上游One API或者New API等轉發項目,請使用OpenAI類型,不要使用此類型,除非你知道你在做什麼。",
|
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "如果你對接的是上游One API或者New API等轉發項目,請使用OpenAI類型,不要使用此類型,除非你知道你在做什麼。",
|
||||||
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "如果使用者請求中包含系統提示詞,則使用此設定拼接到使用者的系統提示詞前面",
|
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "如果使用者請求中包含系統提示詞,則使用此設定拼接到使用者的系統提示詞前面",
|
||||||
@@ -1551,6 +1552,7 @@
|
|||||||
"成功": "成功",
|
"成功": "成功",
|
||||||
"成功兑换额度:": "成功兌換額度:",
|
"成功兑换额度:": "成功兌換額度:",
|
||||||
"成功后切换亲和": "",
|
"成功后切换亲和": "",
|
||||||
|
"渠道禁用后保留亲和": "渠道停用後保留親和",
|
||||||
"成功时自动启用通道": "成功時自動啟用通道",
|
"成功时自动启用通道": "成功時自動啟用通道",
|
||||||
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "我已瞭解禁用兩步驗證將永久刪除所有相關設定和備用碼,此操作不可撤銷",
|
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "我已瞭解禁用兩步驗證將永久刪除所有相關設定和備用碼,此操作不可撤銷",
|
||||||
"我已阅读并同意": "我已閱讀並同意",
|
"我已阅读并同意": "我已閱讀並同意",
|
||||||
|
|||||||
Vendored
+28
-6
@@ -31,18 +31,40 @@ body {
|
|||||||
background-color: var(--semi-color-bg-0);
|
background-color: var(--semi-color-bg-0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 桌面端禁止 body 纵向滚动 - 防止 VChart tooltip 触发页面滚动条 */
|
.app-layout {
|
||||||
@media (min-width: 768px) {
|
min-height: 100vh;
|
||||||
body {
|
min-height: 100dvh;
|
||||||
overflow-y: hidden;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-layout {
|
.app-layout-fixed {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
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 {
|
.app-sider {
|
||||||
height: calc(100vh - 64px);
|
height: calc(100vh - 64px);
|
||||||
height: calc(100dvh - 64px);
|
height: calc(100dvh - 64px);
|
||||||
|
|||||||
Vendored
+2
@@ -1,3 +1,5 @@
|
|||||||
|
import '@douyinfe/semi-ui/react19-adapter';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Copyright (C) 2025 QuantumNous
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -133,9 +133,9 @@ const About = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-[60px] px-2'>
|
<div className='classic-page-fill flex flex-col pt-[60px] px-2'>
|
||||||
{aboutLoaded && about === '' ? (
|
{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
|
<Empty
|
||||||
image={
|
image={
|
||||||
<IllustrationConstruction style={{ width: 150, height: 150 }} />
|
<IllustrationConstruction style={{ width: 150, height: 150 }} />
|
||||||
@@ -156,7 +156,12 @@ const About = () => {
|
|||||||
{about.startsWith('https://') ? (
|
{about.startsWith('https://') ? (
|
||||||
<iframe
|
<iframe
|
||||||
src={about}
|
src={about}
|
||||||
style={{ width: '100%', height: '100vh', border: 'none' }}
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
flex: '1 1 auto',
|
||||||
|
minHeight: 0,
|
||||||
|
border: 'none',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
const Forbidden = () => {
|
const Forbidden = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
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
|
<Empty
|
||||||
image={<IllustrationNoAccess style={{ width: 250, height: 250 }} />}
|
image={<IllustrationNoAccess style={{ width: 250, height: 250 }} />}
|
||||||
darkModeImage={
|
darkModeImage={
|
||||||
|
|||||||
Vendored
+6
-6
@@ -149,20 +149,20 @@ const Home = () => {
|
|||||||
}, [endpointItems.length]);
|
}, [endpointItems.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full overflow-x-hidden'>
|
<div className='classic-page-fill classic-home-page w-full overflow-x-hidden'>
|
||||||
<NoticeModal
|
<NoticeModal
|
||||||
visible={noticeVisible}
|
visible={noticeVisible}
|
||||||
onClose={() => setNoticeVisible(false)}
|
onClose={() => setNoticeVisible(false)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
{homePageContentLoaded && homePageContent === '' ? (
|
{homePageContentLoaded && homePageContent === '' ? (
|
||||||
<div className='w-full overflow-x-hidden'>
|
<div className='classic-home-default w-full overflow-x-hidden'>
|
||||||
{/* Banner 部分 */}
|
{/* 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-indigo' />
|
||||||
<div className='blur-ball blur-ball-teal' />
|
<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 text-center max-w-4xl mx-auto'>
|
||||||
<div className='flex flex-col items-center justify-center mb-6 md:mb-8'>
|
<div className='flex flex-col items-center justify-center mb-6 md:mb-8'>
|
||||||
@@ -335,11 +335,11 @@ const Home = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='overflow-x-hidden w-full'>
|
<div className='classic-page-fill overflow-x-hidden w-full'>
|
||||||
{homePageContent.startsWith('https://') ? (
|
{homePageContent.startsWith('https://') ? (
|
||||||
<iframe
|
<iframe
|
||||||
src={homePageContent}
|
src={homePageContent}
|
||||||
className='w-full h-screen border-none'
|
className='w-full h-full border-none'
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
const NotFound = () => {
|
const NotFound = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
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
|
<Empty
|
||||||
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
|
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
|
||||||
darkModeImage={
|
darkModeImage={
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export default function SettingGlobalModel(props) {
|
|||||||
<Row>
|
<Row>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Form.TextArea
|
<Form.TextArea
|
||||||
label={t('禁用思考处理的模型列表')}
|
label={t('不自动处理思考后缀的模型列表')}
|
||||||
field={'global.thinking_model_blacklist'}
|
field={'global.thinking_model_blacklist'}
|
||||||
placeholder={t('例如:') + '\n' + thinkingExample}
|
placeholder={t('例如:') + '\n' + thinkingExample}
|
||||||
rows={4}
|
rows={4}
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ import ParamOverrideEditorModal from '../../../components/table/channels/modals/
|
|||||||
|
|
||||||
const KEY_ENABLED = 'channel_affinity_setting.enabled';
|
const KEY_ENABLED = 'channel_affinity_setting.enabled';
|
||||||
const KEY_SWITCH_ON_SUCCESS = 'channel_affinity_setting.switch_on_success';
|
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_MAX_ENTRIES = 'channel_affinity_setting.max_entries';
|
||||||
const KEY_DEFAULT_TTL = 'channel_affinity_setting.default_ttl_seconds';
|
const KEY_DEFAULT_TTL = 'channel_affinity_setting.default_ttl_seconds';
|
||||||
const KEY_RULES = 'channel_affinity_setting.rules';
|
const KEY_RULES = 'channel_affinity_setting.rules';
|
||||||
@@ -241,6 +243,7 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
const [inputs, setInputs] = useState({
|
const [inputs, setInputs] = useState({
|
||||||
[KEY_ENABLED]: false,
|
[KEY_ENABLED]: false,
|
||||||
[KEY_SWITCH_ON_SUCCESS]: true,
|
[KEY_SWITCH_ON_SUCCESS]: true,
|
||||||
|
[KEY_KEEP_ON_CHANNEL_DISABLED]: false,
|
||||||
[KEY_MAX_ENTRIES]: 100000,
|
[KEY_MAX_ENTRIES]: 100000,
|
||||||
[KEY_DEFAULT_TTL]: 3600,
|
[KEY_DEFAULT_TTL]: 3600,
|
||||||
[KEY_RULES]: '[]',
|
[KEY_RULES]: '[]',
|
||||||
@@ -858,6 +861,7 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
![
|
![
|
||||||
KEY_ENABLED,
|
KEY_ENABLED,
|
||||||
KEY_SWITCH_ON_SUCCESS,
|
KEY_SWITCH_ON_SUCCESS,
|
||||||
|
KEY_KEEP_ON_CHANNEL_DISABLED,
|
||||||
KEY_MAX_ENTRIES,
|
KEY_MAX_ENTRIES,
|
||||||
KEY_DEFAULT_TTL,
|
KEY_DEFAULT_TTL,
|
||||||
KEY_RULES,
|
KEY_RULES,
|
||||||
@@ -868,6 +872,8 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
currentInputs[key] = toBoolean(props.options[key]);
|
currentInputs[key] = toBoolean(props.options[key]);
|
||||||
else if (key === KEY_SWITCH_ON_SUCCESS)
|
else if (key === KEY_SWITCH_ON_SUCCESS)
|
||||||
currentInputs[key] = toBoolean(props.options[key]);
|
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)
|
else if (key === KEY_MAX_ENTRIES)
|
||||||
currentInputs[key] = Number(props.options[key] || 0) || 0;
|
currentInputs[key] = Number(props.options[key] || 0) || 0;
|
||||||
else if (key === KEY_DEFAULT_TTL)
|
else if (key === KEY_DEFAULT_TTL)
|
||||||
@@ -1003,6 +1009,25 @@ export default function SettingsChannelAffinity(props) {
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Col>
|
</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>
|
</Row>
|
||||||
|
|
||||||
<Divider style={{ marginTop: 12, marginBottom: 12 }} />
|
<Divider style={{ marginTop: 12, marginBottom: 12 }} />
|
||||||
|
|||||||
Vendored
-107
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Vendored
+10
-10
@@ -24,7 +24,7 @@
|
|||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@hugeicons/core-free-icons": "^4.1.4",
|
"@hugeicons/core-free-icons": "^4.1.4",
|
||||||
"@hugeicons/react": "^1.1.6",
|
"@hugeicons/react": "^1.1.6",
|
||||||
"@lobehub/icons": "^5.8.0",
|
"@lobehub/icons": "catalog:",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@tanstack/react-query": "^5.100.14",
|
"@tanstack/react-query": "^5.100.14",
|
||||||
"@tanstack/react-router": "^1.170.8",
|
"@tanstack/react-router": "^1.170.8",
|
||||||
@@ -34,12 +34,12 @@
|
|||||||
"@visactor/vchart": "^2.0.22",
|
"@visactor/vchart": "^2.0.22",
|
||||||
"ai": "^6.0.191",
|
"ai": "^6.0.191",
|
||||||
"auto-skeleton-react": "^1.0.5",
|
"auto-skeleton-react": "^1.0.5",
|
||||||
"axios": "^1.16.1",
|
"axios": "catalog:",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "catalog:",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.3.0",
|
"date-fns": "^4.3.0",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "catalog:",
|
||||||
"i18next": "^26.2.0",
|
"i18next": "^26.2.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
@@ -47,22 +47,22 @@
|
|||||||
"motion": "^12.40.0",
|
"motion": "^12.40.0",
|
||||||
"nanoid": "^5.1.11",
|
"nanoid": "^5.1.11",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "catalog:",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-hook-form": "^7.76.1",
|
"react-hook-form": "^7.76.1",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "catalog:",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "catalog:",
|
||||||
"react-resizable-panels": "^4.11.2",
|
"react-resizable-panels": "^4.11.2",
|
||||||
"react-top-loading-bar": "^3.0.2",
|
"react-top-loading-bar": "^3.0.2",
|
||||||
"recharts": "3.8.1",
|
"recharts": "3.8.1",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "catalog:",
|
||||||
"shiki": "^4.1.0",
|
"shiki": "^4.1.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"sse.js": "^2.8.0",
|
"sse.js": "catalog:",
|
||||||
"streamdown": "^2.5.0",
|
"streamdown": "^2.5.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"knip": "^6.14.2",
|
"knip": "^6.14.2",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "catalog:",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
"shadcn": "^4.8.0",
|
"shadcn": "^4.8.0",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
|
|||||||
Vendored
+1
@@ -65,6 +65,7 @@ export default defineConfig(({ envMode }) => {
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
|
strictPort: true,
|
||||||
proxy: devProxy,
|
proxy: devProxy,
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
+99
-1
@@ -31,7 +31,99 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Label } from '@/components/ui/label'
|
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<
|
type FormFieldContextValue<
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
@@ -90,11 +182,13 @@ const FormItemContext = React.createContext<FormItemContextValue>(
|
|||||||
|
|
||||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
const id = React.useId()
|
const id = React.useId()
|
||||||
|
const formContext = React.useContext(FormRootContext)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItemContext.Provider value={{ id }}>
|
<FormItemContext.Provider value={{ id }}>
|
||||||
<div
|
<div
|
||||||
data-slot='form-item'
|
data-slot='form-item'
|
||||||
|
data-form-root={formContext?.id}
|
||||||
className={cn('grid gap-2', className)}
|
className={cn('grid gap-2', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -124,11 +218,13 @@ function FormControl({
|
|||||||
...props
|
...props
|
||||||
}: { children: React.ReactElement } & Record<string, unknown>) {
|
}: { children: React.ReactElement } & Record<string, unknown>) {
|
||||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
const formContext = React.useContext(FormRootContext)
|
||||||
|
|
||||||
return useRender({
|
return useRender({
|
||||||
render: children,
|
render: children,
|
||||||
props: {
|
props: {
|
||||||
'data-slot': 'form-control',
|
'data-slot': 'form-control',
|
||||||
|
'data-form-root': formContext?.id,
|
||||||
id: formItemId,
|
id: formItemId,
|
||||||
'aria-describedby': !error
|
'aria-describedby': !error
|
||||||
? `${formDescriptionId}`
|
? `${formDescriptionId}`
|
||||||
@@ -154,6 +250,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
|||||||
|
|
||||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
const { error, formMessageId } = useFormField()
|
const { error, formMessageId } = useFormField()
|
||||||
|
const formContext = React.useContext(FormRootContext)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const body = error ? String(error?.message ?? '') : props.children
|
const body = error ? String(error?.message ?? '') : props.children
|
||||||
|
|
||||||
@@ -166,6 +263,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
|||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
data-slot='form-message'
|
data-slot='form-message'
|
||||||
|
data-form-root={formContext?.id}
|
||||||
id={formMessageId}
|
id={formMessageId}
|
||||||
className={cn('text-destructive text-sm', className)}
|
className={cn('text-destructive text-sm', className)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
+23
-13
@@ -24,7 +24,7 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { type SubmitErrorHandler, useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
@@ -140,6 +140,7 @@ import {
|
|||||||
hasModelConfigChanged,
|
hasModelConfigChanged,
|
||||||
findMissingModelsInMapping,
|
findMissingModelsInMapping,
|
||||||
validateModelMappingJson,
|
validateModelMappingJson,
|
||||||
|
hasAdvancedSettingsErrors,
|
||||||
} from '../../lib'
|
} from '../../lib'
|
||||||
import {
|
import {
|
||||||
collectInvalidStatusCodeEntries,
|
collectInvalidStatusCodeEntries,
|
||||||
@@ -204,7 +205,6 @@ function readAdvancedSettingsPreference(): boolean {
|
|||||||
|
|
||||||
function hasAdvancedSettingsValues(values: ChannelFormValues): boolean {
|
function hasAdvancedSettingsValues(values: ChannelFormValues): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
values.model_mapping?.trim() ||
|
|
||||||
values.param_override?.trim() ||
|
values.param_override?.trim() ||
|
||||||
values.header_override?.trim() ||
|
values.header_override?.trim() ||
|
||||||
values.status_code_mapping?.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
|
// Handle drawer close
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(v: boolean) => {
|
(v: boolean) => {
|
||||||
@@ -1020,16 +1040,6 @@ export function ChannelMutateDrawer({
|
|||||||
[onOpenChange, form]
|
[onOpenChange, form]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleAdvancedSettingsOpenChange = useCallback((nextOpen: boolean) => {
|
|
||||||
setAdvancedSettingsOpen(nextOpen)
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
ADVANCED_SETTINGS_EXPANDED_KEY,
|
|
||||||
String(nextOpen)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||||
@@ -1060,7 +1070,7 @@ export function ChannelMutateDrawer({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
id='channel-form'
|
id='channel-form'
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
|
||||||
className={sideDrawerFormClassName('gap-5')}
|
className={sideDrawerFormClassName('gap-5')}
|
||||||
>
|
>
|
||||||
{isChannelDetailLoading ? (
|
{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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
*/
|
*/
|
||||||
// Re-export all library functions
|
// Re-export all library functions
|
||||||
export * from './channel-actions'
|
export * from './channel-actions'
|
||||||
|
export * from './channel-form-errors'
|
||||||
export * from './channel-form'
|
export * from './channel-form'
|
||||||
export * from './channel-type-config'
|
export * from './channel-type-config'
|
||||||
export * from './channel-utils'
|
export * from './channel-utils'
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export function CCSwitchDialog(props: Props) {
|
|||||||
onValueChange={setName}
|
onValueChange={setName}
|
||||||
placeholder={currentConfig.defaultName}
|
placeholder={currentConfig.defaultName}
|
||||||
emptyText=''
|
emptyText=''
|
||||||
|
allowCustomValue={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ export function ModelMutateDrawer({
|
|||||||
'grok.violation_deduction_amount': 0,
|
'grok.violation_deduction_amount': 0,
|
||||||
'channel_affinity_setting.enabled': false,
|
'channel_affinity_setting.enabled': false,
|
||||||
'channel_affinity_setting.switch_on_success': true,
|
'channel_affinity_setting.switch_on_success': true,
|
||||||
|
'channel_affinity_setting.keep_on_channel_disabled': false,
|
||||||
'channel_affinity_setting.max_entries': 100000,
|
'channel_affinity_setting.max_entries': 100000,
|
||||||
'channel_affinity_setting.default_ttl_seconds': 3600,
|
'channel_affinity_setting.default_ttl_seconds': 3600,
|
||||||
'channel_affinity_setting.rules': '[]',
|
'channel_affinity_setting.rules': '[]',
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
|
|||||||
const tags = parseTags(props.model.tags)
|
const tags = parseTags(props.model.tags)
|
||||||
const groups = props.model.enable_groups || []
|
const groups = props.model.enable_groups || []
|
||||||
const endpoints = props.model.supported_endpoint_types || []
|
const endpoints = props.model.supported_endpoint_types || []
|
||||||
const vendorIcon = props.model.vendor_icon
|
const modelIconKey = props.model.icon || props.model.vendor_icon
|
||||||
? getLobeIcon(props.model.vendor_icon, 28)
|
const modelIcon = modelIconKey
|
||||||
|
? getLobeIcon(modelIconKey, 28)
|
||||||
: null
|
: null
|
||||||
const initial = props.model.model_name?.charAt(0).toUpperCase() || '?'
|
const initial = props.model.model_name?.charAt(0).toUpperCase() || '?'
|
||||||
const isDynamicPricing =
|
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 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='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'>
|
<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'>
|
<span className='text-muted-foreground text-sm font-bold'>
|
||||||
{initial}
|
{initial}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -268,8 +268,9 @@ function OverviewSummaryGrid(props: { model: PricingModel }) {
|
|||||||
function ModelHeader(props: { model: PricingModel }) {
|
function ModelHeader(props: { model: PricingModel }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const model = props.model
|
const model = props.model
|
||||||
const vendorIcon = model.vendor_icon
|
const modelIconKey = model.icon || model.vendor_icon
|
||||||
? getLobeIcon(model.vendor_icon, 20)
|
const modelIcon = modelIconKey
|
||||||
|
? getLobeIcon(modelIconKey, 20)
|
||||||
: null
|
: null
|
||||||
const description = model.description || model.vendor_description || null
|
const description = model.description || model.vendor_description || null
|
||||||
const tags = parseTags(model.tags)
|
const tags = parseTags(model.tags)
|
||||||
@@ -281,7 +282,7 @@ function ModelHeader(props: { model: PricingModel }) {
|
|||||||
return (
|
return (
|
||||||
<header className='pb-4'>
|
<header className='pb-4'>
|
||||||
<div className='flex items-center gap-2.5'>
|
<div className='flex items-center gap-2.5'>
|
||||||
{vendorIcon}
|
{modelIcon}
|
||||||
<h1 className='font-mono text-xl font-bold tracking-tight sm:text-2xl'>
|
<h1 className='font-mono text-xl font-bold tracking-tight sm:text-2xl'>
|
||||||
{model.model_name}
|
{model.model_name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -106,13 +106,14 @@ export function usePricingColumns(
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const model = row.original
|
const model = row.original
|
||||||
const vendorIcon = model.vendor_icon
|
const modelIconKey = model.icon || model.vendor_icon
|
||||||
? getLobeIcon(model.vendor_icon, 14)
|
const modelIcon = modelIconKey
|
||||||
|
? getLobeIcon(modelIconKey, 14)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex min-w-[200px] items-center gap-2'>
|
<div className='flex min-w-[200px] items-center gap-2'>
|
||||||
{vendorIcon}
|
{modelIcon}
|
||||||
<span className='truncate font-mono text-sm font-medium'>
|
<span className='truncate font-mono text-sm font-medium'>
|
||||||
{model.model_name}
|
{model.model_name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+1
@@ -31,6 +31,7 @@ export type PricingModel = {
|
|||||||
id: number
|
id: number
|
||||||
model_name: string
|
model_name: string
|
||||||
description?: string
|
description?: string
|
||||||
|
icon?: string
|
||||||
vendor_id?: number
|
vendor_id?: number
|
||||||
vendor_name?: string
|
vendor_name?: string
|
||||||
vendor_icon?: string
|
vendor_icon?: string
|
||||||
|
|||||||
+20
-3
@@ -111,6 +111,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
|
|||||||
Math.ceil(Number(plan.price_amount || 0) * quotaPerUnit)
|
Math.ceil(Number(plan.price_amount || 0) * quotaPerUnit)
|
||||||
)
|
)
|
||||||
const userQuota = Math.max(0, Number(props.userQuota || 0))
|
const userQuota = Math.max(0, Number(props.userQuota || 0))
|
||||||
|
const allowBalancePay = plan.allow_balance_pay !== false
|
||||||
const insufficientBalance = userQuota < balanceCost
|
const insufficientBalance = userQuota < balanceCost
|
||||||
const limitReached =
|
const limitReached =
|
||||||
(props.purchaseLimit || 0) > 0 &&
|
(props.purchaseLimit || 0) > 0 &&
|
||||||
@@ -232,6 +233,10 @@ export function SubscriptionPurchaseDialog(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePayBalance = async () => {
|
const handlePayBalance = async () => {
|
||||||
|
if (!allowBalancePay) {
|
||||||
|
toast.error(t('This plan does not allow balance redemption'))
|
||||||
|
return
|
||||||
|
}
|
||||||
setPaying(true)
|
setPaying(true)
|
||||||
try {
|
try {
|
||||||
const res = await paySubscriptionBalance({ plan_id: plan.id })
|
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 className='text-muted-foreground'>{t('Available')}</span>
|
||||||
<span>{formatQuota(userQuota)}</span>
|
<span>{formatQuota(userQuota)}</span>
|
||||||
</div>
|
</div>
|
||||||
{insufficientBalance && (
|
{!allowBalancePay ? (
|
||||||
<Alert variant='destructive'>
|
<Alert variant='destructive'>
|
||||||
<AlertDescription>{t('Insufficient balance')}</AlertDescription>
|
<AlertDescription>
|
||||||
|
{t('This plan does not allow balance redemption')}
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
) : (
|
||||||
|
insufficientBalance && (
|
||||||
|
<Alert variant='destructive'>
|
||||||
|
<AlertDescription>
|
||||||
|
{t('Insufficient balance')}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
onClick={handlePayBalance}
|
onClick={handlePayBalance}
|
||||||
disabled={paying || limitReached || insufficientBalance}
|
disabled={
|
||||||
|
paying || limitReached || !allowBalancePay || insufficientBalance
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t('Pay with Balance')}
|
{t('Pay with Balance')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+18
@@ -461,6 +461,24 @@ export function SubscriptionsMutateDrawer({
|
|||||||
</FormItem>
|
</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>
|
</div>
|
||||||
</SideDrawerSection>
|
</SideDrawerSection>
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function getPlanFormSchema(t: TFunction) {
|
|||||||
quota_reset_custom_seconds: z.coerce.number().min(0).optional(),
|
quota_reset_custom_seconds: z.coerce.number().min(0).optional(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
sort_order: z.coerce.number(),
|
sort_order: z.coerce.number(),
|
||||||
|
allow_balance_pay: z.boolean(),
|
||||||
max_purchase_per_user: z.coerce.number().min(0),
|
max_purchase_per_user: z.coerce.number().min(0),
|
||||||
total_amount: z.coerce.number().min(0),
|
total_amount: z.coerce.number().min(0),
|
||||||
upgrade_group: z.string().optional(),
|
upgrade_group: z.string().optional(),
|
||||||
@@ -61,6 +62,7 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = {
|
|||||||
quota_reset_custom_seconds: 0,
|
quota_reset_custom_seconds: 0,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
|
allow_balance_pay: true,
|
||||||
max_purchase_per_user: 0,
|
max_purchase_per_user: 0,
|
||||||
total_amount: 0,
|
total_amount: 0,
|
||||||
upgrade_group: '',
|
upgrade_group: '',
|
||||||
@@ -81,6 +83,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
|
|||||||
quota_reset_custom_seconds: Number(plan.quota_reset_custom_seconds || 0),
|
quota_reset_custom_seconds: Number(plan.quota_reset_custom_seconds || 0),
|
||||||
enabled: plan.enabled !== false,
|
enabled: plan.enabled !== false,
|
||||||
sort_order: Number(plan.sort_order || 0),
|
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),
|
max_purchase_per_user: Number(plan.max_purchase_per_user || 0),
|
||||||
total_amount: quotaUnitsToDollars(Number(plan.total_amount || 0)),
|
total_amount: quotaUnitsToDollars(Number(plan.total_amount || 0)),
|
||||||
upgrade_group: plan.upgrade_group || '',
|
upgrade_group: plan.upgrade_group || '',
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const subscriptionPlanSchema = z.object({
|
|||||||
quota_reset_custom_seconds: z.number().optional(),
|
quota_reset_custom_seconds: z.number().optional(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
sort_order: z.number(),
|
sort_order: z.number(),
|
||||||
|
allow_balance_pay: z.boolean().optional().default(true),
|
||||||
max_purchase_per_user: z.number(),
|
max_purchase_per_user: z.number(),
|
||||||
total_amount: z.number(),
|
total_amount: z.number(),
|
||||||
upgrade_group: z.string().optional(),
|
upgrade_group: z.string().optional(),
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ export function ChannelAffinitySection(props: Props) {
|
|||||||
const [switchOnSuccess, setSwitchOnSuccess] = useState(
|
const [switchOnSuccess, setSwitchOnSuccess] = useState(
|
||||||
props.defaultValues['channel_affinity_setting.switch_on_success']
|
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(
|
const [maxEntries, setMaxEntries] = useState(
|
||||||
props.defaultValues['channel_affinity_setting.max_entries']
|
props.defaultValues['channel_affinity_setting.max_entries']
|
||||||
)
|
)
|
||||||
@@ -136,6 +139,9 @@ export function ChannelAffinitySection(props: Props) {
|
|||||||
setSwitchOnSuccess(
|
setSwitchOnSuccess(
|
||||||
props.defaultValues['channel_affinity_setting.switch_on_success']
|
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'])
|
setMaxEntries(props.defaultValues['channel_affinity_setting.max_entries'])
|
||||||
setDefaultTtl(
|
setDefaultTtl(
|
||||||
props.defaultValues['channel_affinity_setting.default_ttl_seconds']
|
props.defaultValues['channel_affinity_setting.default_ttl_seconds']
|
||||||
@@ -231,6 +237,14 @@ export function ChannelAffinitySection(props: Props) {
|
|||||||
key: 'channel_affinity_setting.switch_on_success',
|
key: 'channel_affinity_setting.switch_on_success',
|
||||||
value: String(switchOnSuccess),
|
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 (
|
if (
|
||||||
maxEntries !==
|
maxEntries !==
|
||||||
props.defaultValues['channel_affinity_setting.max_entries']
|
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.'
|
'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 />
|
<Separator />
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface CacheStats {
|
|||||||
export interface ChannelAffinitySettings {
|
export interface ChannelAffinitySettings {
|
||||||
'channel_affinity_setting.enabled': boolean
|
'channel_affinity_setting.enabled': boolean
|
||||||
'channel_affinity_setting.switch_on_success': boolean
|
'channel_affinity_setting.switch_on_success': boolean
|
||||||
|
'channel_affinity_setting.keep_on_channel_disabled': boolean
|
||||||
'channel_affinity_setting.max_entries': number
|
'channel_affinity_setting.max_entries': number
|
||||||
'channel_affinity_setting.default_ttl_seconds': number
|
'channel_affinity_setting.default_ttl_seconds': number
|
||||||
'channel_affinity_setting.rules': string
|
'channel_affinity_setting.rules': string
|
||||||
|
|||||||
@@ -231,10 +231,10 @@ export function ClaudeSettingsCard({ defaultValues }: ClaudeSettingsCardProps) {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<SettingsSwitchItem>
|
<SettingsSwitchItem>
|
||||||
<SettingsSwitchContent>
|
<SettingsSwitchContent>
|
||||||
<FormLabel>{t('Thinking Adapter')}</FormLabel>
|
<FormLabel>{t('Thinking Suffix Adapter')}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t(
|
{t(
|
||||||
'Translate `-thinking` suffixes into Anthropic native thinking models while keeping pricing predictable.'
|
'Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.'
|
||||||
)}
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</SettingsSwitchContent>
|
</SettingsSwitchContent>
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ export function GeminiSettingsCard({ defaultValues }: GeminiSettingsCardProps) {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<SettingsSwitchItem>
|
<SettingsSwitchItem>
|
||||||
<SettingsSwitchContent>
|
<SettingsSwitchContent>
|
||||||
<FormLabel>{t('Thinking Adapter')}</FormLabel>
|
<FormLabel>{t('Thinking Suffix Adapter')}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t('Supports `-thinking`, `-thinking-')}
|
{t('Supports `-thinking`, `-thinking-')}
|
||||||
{'{{budget}}'}
|
{'{{budget}}'}
|
||||||
|
|||||||
@@ -227,7 +227,9 @@ export function GlobalSettingsCard({ defaultValues }: GlobalSettingsCardProps) {
|
|||||||
name='global.thinking_model_blacklist'
|
name='global.thinking_model_blacklist'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('Disable thinking processing models')}</FormLabel>
|
<FormLabel>
|
||||||
|
{t('Models that skip thinking suffix processing')}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
rows={4}
|
rows={4}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const defaultModelSettings: ModelSettings = {
|
|||||||
'group_ratio_setting.group_special_usable_group': '{}',
|
'group_ratio_setting.group_special_usable_group': '{}',
|
||||||
'channel_affinity_setting.enabled': false,
|
'channel_affinity_setting.enabled': false,
|
||||||
'channel_affinity_setting.switch_on_success': true,
|
'channel_affinity_setting.switch_on_success': true,
|
||||||
|
'channel_affinity_setting.keep_on_channel_disabled': false,
|
||||||
'channel_affinity_setting.max_entries': 100000,
|
'channel_affinity_setting.max_entries': 100000,
|
||||||
'channel_affinity_setting.default_ttl_seconds': 3600,
|
'channel_affinity_setting.default_ttl_seconds': 3600,
|
||||||
'channel_affinity_setting.rules': '[]',
|
'channel_affinity_setting.rules': '[]',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user