Compare commits

..

72 Commits

Author SHA1 Message Date
CaIon a706f00287 feat(EditChannelModal): persist advanced settings state in local storage
Added functionality to save and restore the state of advanced settings in the EditChannelModal using local storage. This enhancement allows users to maintain their preferences when editing channels, improving the overall user experience.
2026-04-02 00:17:21 +08:00
Calcium-Ion 7efb1922fe Merge pull request #3526 from feitianbubu/pr/e560265b6e57aa7b95bc98cb53397ef0a3082d9d
支持wan2.7生图-wan2.7-image
2026-04-02 00:15:04 +08:00
Calcium-Ion 89fe99f3bd Merge pull request #3512 from imlhb/patch-2
fix: prevent double-counting of image count n in billing
2026-04-02 00:14:39 +08:00
feitianbubu e5b5331d3b feat: wan 2.7 support N for gen images number 2026-04-01 17:39:50 +08:00
feitianbubu 18373c6eac feat: add wan 2.7 2026-04-01 17:39:11 +08:00
Calcium-Ion 5b47011e08 Merge pull request #3450 from QuantumNous/dependabot/npm_and_yarn/electron/picomatch-4.0.4
chore(deps-dev): bump picomatch from 4.0.3 to 4.0.4 in /electron
2026-04-01 14:35:30 +08:00
CaIon ab99c30884 fix: move image count n to OtherRatio to prevent double-counting
The previous commit commented out AddOtherRatio("n") in the Ali
adaptor to fix double-counting but this could cause billing evasion
when n is specified via extra["parameters"] instead of request.N.

Root cause: ImagePriceRatio in GetTokenCountMeta() already included
n, AND channel adaptors added OtherRatio("n"), resulting in n²
billing.

Proper fix:
- Remove n from ImagePriceRatio (keep sizeRatio * qualityRatio only)
- In ImageHelper, add default OtherRatio("n") when adaptor hasn't
  set one; set fallback tokens to 1 (base unit)
- Restore Ali adaptor's AddOtherRatio("n") — it uses actual upstream
  parameters/response count, preventing billing evasion
2026-03-31 23:58:10 +08:00
CaIon 670abee2f0 fix(EditChannelModal): enhance clipboard handling with error checks
Added checks to ensure clipboard functionality is available before attempting to read from it. Improved error handling during clipboard read operations to prevent unhandled exceptions.
2026-03-31 21:42:36 +08:00
CaIon 8bb9a42f68 feat: add clipboard magic string for quick channel creation from token copy
When copying a token, users can now choose "Copy Connection String" which
encodes both the API key and server URL as a JSON clipboard payload
(type: newapi_channel_conn). When opening the channel creation form, the
clipboard is auto-detected and a banner offers to fill key + base_url,
eliminating repeated tab-switching when connecting to another new-api instance.
2026-03-31 19:39:23 +08:00
CaIon d22f889e5d fix(xAI): set MaxTokens to nil when MaxCompletionTokens is 0 for grok-3-mini model 2026-03-31 19:16:16 +08:00
Calcium-Ion 3734059da7 Merge pull request #3462 from DaZuiZui/main
docs(zh-TW): fix missing content and add partner logo
2026-03-31 19:02:15 +08:00
Calcium-Ion 26ce873f8b Merge pull request #3474 from wans10/main
fix(dashboard): 修复消耗分布图表悬浮时滚动条闪烁
2026-03-31 18:57:16 +08:00
CaIon e099117c61 refactor: use POST for account binding endpoints and normalize reset responses
- Switch /api/oauth/email/bind and /api/oauth/wechat/bind from GET to
  POST with JSON body for better REST semantics
- Normalize password reset endpoint to return consistent responses
- Apply url.QueryEscape to WeChat code parameter for robustness
2026-03-31 18:44:40 +08:00
CaIon 310d618a16 style: enhance footer layout and add custom class for styling
- Refactor Footer component to use a semantic <footer> element for better accessibility.
- Update CSS to include a new class for the custom footer, allowing for relative positioning.
- Adjust layout to improve responsiveness and visual alignment of footer content.
2026-03-31 18:41:44 +08:00
CaIon 20399d3c8f fix: harden SSRF protection for unauthenticated and user-level endpoints
- Add ValidateURLWithFetchSetting check before fetching MJ image URLs
  in RelayMidjourneyImage (unauthenticated endpoint)
- Add ValidateURLWithFetchSetting check before fetching video URLs
  in VideoProxy (upstream-controlled URL)
- Enable ApplyIPFilterForDomain by default to prevent DNS rebinding
  bypass of SSRF protection
- Elevate FetchModels endpoint from AdminAuth to RootAuth
- Update frontend: mark domain IP filtering as recommended, update
  description and i18n translations (zh-CN/zh-TW/en/fr/ja/ru/vi)
2026-03-31 17:57:47 +08:00
刘泓宾 53aeee4ff7 Comment out price data adjustment logic
Comment out code that modifies price data based on image count.
测试发现,如果是接入阿里百炼平台的qwen-image-2.0系列模型,这边计费的时候会出现 0.2*n*倍率*n的情况,最前面的0.2*n会直接显示为模型价格。
例如:
日志详情	模型价格 $0.600000,专属倍率 0.3
其他详情	大小 1080*1920, 品质 standard, 生成数量 3, 其他倍率 n: 3.000000
计费过程	模型价格:$0.600000 * 专属倍率:0.3 = $0.180000
2026-03-31 17:12:06 +08:00
CaIon 5238f279db feat: record stream interruption reasons via StreamStatus
- Add StreamStatus type (relay/common) to track stream end reason
  (done/timeout/client_gone/scanner_error/eof/panic/ping_fail) and
  accumulate soft errors during streaming via sync.Once + sync.Mutex.
- Add StreamResult (relay/helper) as the callback interface: adapters
  call sr.Error() for soft errors, sr.Stop() for fatal, sr.Done() for
  normal completion. No early-return problem — multiple errors per chunk
  are naturally supported.
- Refactor StreamScannerHandler callback from func(string) bool to
  func(string, *StreamResult). All 9 channel adapters updated.
- Write stream_status into log other JSON field (admin-only) with
  status ok/error, end_reason, error_count, and error messages.
- Frontend: display stream status in log detail expansion for admins.
2026-03-31 16:54:39 +08:00
CaIon 5402bf417d feat: expose i18n instance to the global window object for easier access 2026-03-31 16:54:39 +08:00
Seefs c766913baf Merge pull request #3507 from QuantumNous/dependabot/go_modules/golang.org/x/image-0.38.0
chore(deps): bump golang.org/x/image from 0.23.0 to 0.38.0
2026-03-31 16:52:35 +08:00
dependabot[bot] 40dc43f44e chore(deps): bump golang.org/x/image from 0.23.0 to 0.38.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.23.0 to 0.38.0.
- [Commits](https://github.com/golang/image/compare/v0.23.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.38.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 16:24:46 +00:00
wans10 b2dd4acc9f fix(dashboard): 修复消耗分布图表悬浮时滚动条闪烁 2026-03-28 12:14:22 +08:00
哇塞大嘴好帥 4e492b26f6 Update README.zh_TW.md 2026-03-27 17:34:28 +08:00
哇塞大嘴好帥 82b750398c Update README.zh_TW.md 2026-03-27 17:15:12 +08:00
Seefs fbf235d222 Merge pull request #3461 from feitianbubu/pr/0ec3d7a1b2cc1375b5c7fe041ae94d714eb03a69
feat: prevent metadata from overriding model fields
2026-03-27 15:36:01 +08:00
feitianbubu 62b9aaa520 feat: prevent metadata from overriding model fields 2026-03-27 15:31:41 +08:00
dependabot[bot] 814a3f5124 chore(deps-dev): bump picomatch from 4.0.3 to 4.0.4 in /electron
Bumps [picomatch](https://github.com/micromatch/picomatch) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 07:58:57 +00:00
Calcium-Ion 22b6b16702 Merge pull request #3440 from seefs001/refactor/expose-skip-retry-option
refactor: expose skip-retry option and show it in rules list
2026-03-25 14:11:37 +08:00
Seefs 6154b8e3cd refactor: expose skip-retry option and show it in rules list 2026-03-25 14:06:35 +08:00
Calcium-Ion ff66288e3a Merge pull request #3438 from seefs001/fix/openrouter-usage
fix: restore pre-3400 OpenRouter billing semantics
2026-03-25 14:02:08 +08:00
Seefs 926e1781dd fix: preserve cache usage in openai-to-claude response conversion 2026-03-25 13:49:21 +08:00
Seefs d4a470a638 fix: restore pre-3400 OpenRouter billing semantics 2026-03-25 13:24:52 +08:00
Seefs 9f61407bf0 fix: restore pre-3400 OpenRouter billing semantics 2026-03-25 13:11:51 +08:00
CaIon dbf900a531 fix: restore doubao coding plan deprecation and regex ignored models lost during conflict resolution 2026-03-25 00:04:01 +08:00
CaIon 7399e4721b feat: add slide-in animations and update translations for new UI elements
# Conflicts:
#	web/src/components/table/channels/modals/EditChannelModal.jsx
2026-03-24 23:57:58 +08:00
CaIon a5e20269dd security: harden Docker and release CI workflows
- Pin all GitHub Actions to commit SHA to prevent supply chain attacks
- Enable SLSA provenance attestation (mode=max) and SBOM generation
- Add cosign keyless signing for Docker images via GitHub OIDC
- Capture and output image digests to GitHub Job Summary
- Pin Dockerfile base images to digest (bun:1, golang:1.26.1-alpine, debian:bookworm-slim)
- Add SHA256 checksum generation for binary releases (Linux/macOS/Windows)
- Update actions/checkout v3->v4, actions/setup-go v3->v5 in release.yml
2026-03-24 23:56:15 +08:00
Calcium-Ion 9ae9040b3c Merge pull request #3401 from seefs001/fix/convert-openai-detail-field
fix: the "detail" field is empty, an empty field was sent to upstream
2026-03-23 15:04:06 +08:00
Calcium-Ion 0191a68d4e Merge pull request #3400 from seefs001/fix/openai-usage
refactor: optimize billing flow for OpenAI-to-Anthropic convert
2026-03-23 15:03:57 +08:00
Calcium-Ion 16221f8279 Merge pull request #3399 from seefs001/refactor/codex-usage
Refactor/codex usage
2026-03-23 15:03:47 +08:00
Calcium-Ion 763c3ff709 Merge pull request #3331 from seefs001/fix/claude-beta-query
fix: apply forced beta query at final upstream URL stage
2026-03-23 15:03:36 +08:00
Calcium-Ion c667e4706a Merge pull request #3333 from seefs001/fix/channel-affinity-disable
fix: honor channel affinity skip-retry when channel is disabled
2026-03-23 15:03:23 +08:00
Calcium-Ion 216b94dac0 Merge pull request #3335 from seefs001/chore/adjuct-default-settings
adjuct default settings
2026-03-23 15:03:01 +08:00
Calcium-Ion 49eb533aaf Merge pull request #3381 from seefs001/feature/regex-ignored-upstream-models
feat: support regex-prefixed ignored upstream models
2026-03-23 15:02:44 +08:00
Calcium-Ion 7693edae53 Merge pull request #3393 from seefs001/fix/oauth-bind
fix: oauth bind callback handling
2026-03-23 15:02:34 +08:00
Seefs ded4a124e2 fix: the "detail" field is empty, an empty field was sent to the upstream system. 2026-03-23 15:00:20 +08:00
Calcium-Ion d6982c8182 Merge pull request #3379 from seefs001/refactor/rm-coding-plan
fix: disable doubao coding plan selection
2026-03-23 14:53:13 +08:00
Seefs 9ecad90652 refactor: optimize billing flow for OpenAI-to-Anthropic convert 2026-03-23 14:22:12 +08:00
Seefs 929b5060ea refactor: simplify codex account modal and collapse raw json by default 2026-03-23 13:54:54 +08:00
Seefs 755ece2f01 refactor: simplify codex account modal and collapse raw json by default 2026-03-23 00:58:59 +08:00
Seefs f40eb4e5d2 fix: oauth bind callback handling 2026-03-23 00:48:55 +08:00
Seefs 45f65c297b feat: support regex-prefixed ignored upstream models 2026-03-22 15:43:03 +08:00
Seefs 6c074ef897 fix: disable doubao coding plan selection 2026-03-22 15:01:09 +08:00
CaIon deff59a5be fix: increase StreamScannerMaxBufferMB limit and add handling for gpt-5.4-nano prefix 2026-03-22 13:55:10 +08:00
Seefs 3c516084f8 Merge pull request #3360 from lcq225/docs/improve-bt-installation-guide
docs: 完善宝塔面板部署教程并修复链接错误
2026-03-22 00:43:13 +08:00
Seefs 4d675b4d1f Merge pull request #3357 from wenyifancc/cache_llama_cpp
feat: Add support for counting cache-hit tokens in llama.cpp
2026-03-22 00:39:49 +08:00
Seefs 87b426f306 Merge pull request #3369 from RedwindA/feat/logsManagement
feat: add server log file management to performance settings
2026-03-22 00:32:01 +08:00
RedwindA 49db5147c3 fix: align log cleanup button with other controls in the row 2026-03-21 21:48:31 +08:00
RedwindA 13122aa0fa fix: refresh log info on partial delete failure 2026-03-21 21:11:52 +08:00
RedwindA dcd0911612 fix: log management race condition, partial delete reporting, and UX issues
- Fix data race on gin.DefaultWriter during log rotation by adding LogWriterMu
- Report partial failure when some log files fail to delete instead of always returning success
- Fix misleading "logging disabled" banner shown before API responds
- Fix en.json translation for numeric validation message
2026-03-21 20:40:39 +08:00
RedwindA e904579a5b feat: add server log file management to performance settings
Add API endpoints (GET/DELETE /api/performance/logs) to list and clean up
server log files by count or by age. Track the active log file path in
the logger to prevent deleting the currently open log. Add a management
UI section in the performance settings page with log directory info,
file statistics, and cleanup controls. Includes i18n translations for
all supported languages (en, fr, ja, ru, vi, zh-CN, zh-TW).
2026-03-21 20:06:49 +08:00
mm413 e80d867f38 Update docs/installation/BT.md
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-20 20:13:30 +08:00
lcq225 cf86fe5fea docs: 完善宝塔面板部署教程并修复链接错误
- 完善 docs/installation/BT.md,从 2 行扩展为完整教程
- 包含前置要求、安装步骤、配置说明、常见问题
- 修复 README.zh_CN.md 中的链接错误
- 所有内容基于官方文档 https://docs.newapi.pro 编写
2026-03-20 20:06:09 +08:00
Calcium-Ion 42846c692e Merge pull request #3359 from seefs001/feature/normalize-bearer-type
fix: normalize generic oauth bearer token type
2026-03-20 17:08:46 +08:00
Seefs 1911520eba fix: normalize generic oauth bearer token type 2026-03-20 17:07:03 +08:00
wenyifan 2c3ae32c8e fix map 2026-03-20 16:48:04 +08:00
CaIon 64f41efc47 chore: remove FUNDING.yml file as it is no longer needed 2026-03-20 16:44:30 +08:00
wenyifan 498199b37d fix code quality 2026-03-20 16:38:48 +08:00
wenyifan ff29900f30 feat: Add support for counting cache-hit tokens in llama.cpp OpenAI-Compatible API 2026-03-20 16:10:18 +08:00
Seefs eff51857d0 refactor: show codex account info tag and highlight plan type in usage modal 2026-03-20 16:00:36 +08:00
Seefs e9f8f62796 fix: raise default overload disk threshold to 95% 2026-03-19 16:58:13 +08:00
Seefs 5fe8e98eeb fix: default codex and claude channel affinity templates to skip retry on failure 2026-03-19 16:56:28 +08:00
Seefs e520977efc fix: apply forced beta query at final upstream URL stage 2026-03-19 15:49:50 +08:00
Seefs b09337e6ed fix: honor channel affinity skip-retry when preferred channel is disabled 2026-03-18 16:08:31 +08:00
98 changed files with 4021 additions and 1839 deletions
-12
View File
@@ -1,12 +0,0 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: ['https://afdian.com/a/new-api'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+39 -11
View File
@@ -27,9 +27,10 @@ jobs:
permissions:
packages: write
contents: read
id-token: write
steps:
- name: Check out (shallow)
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 1
@@ -46,16 +47,16 @@ jobs:
run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -63,14 +64,15 @@ jobs:
- name: Extract metadata (labels)
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: |
calciumion/new-api
ghcr.io/${{ env.GHCR_REPOSITORY }}
- name: Build & push single-arch (to both registries)
uses: docker/build-push-action@v6
id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
platforms: ${{ matrix.platform }}
@@ -83,8 +85,25 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
sbom: false
provenance: mode=max
sbom: true
- name: Install cosign
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
- name: Sign image with cosign
run: |
cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
cosign sign --yes ghcr.io/${{ env.GHCR_REPOSITORY }}@${{ steps.build.outputs.digest }}
- name: Output digest
run: |
echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "calciumion/new-api:alpha-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
echo "ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
create_manifests:
name: Create multi-arch manifests (Docker Hub + GHCR)
@@ -95,7 +114,7 @@ jobs:
contents: read
steps:
- name: Check out (shallow)
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 1
@@ -110,7 +129,7 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -130,7 +149,7 @@ jobs:
calciumion/new-api:${VERSION}-arm64
- name: Log in to GHCR
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -149,3 +168,12 @@ jobs:
-t ghcr.io/${GHCR_REPOSITORY}:${VERSION} \
ghcr.io/${GHCR_REPOSITORY}:${VERSION}-amd64 \
ghcr.io/${GHCR_REPOSITORY}:${VERSION}-arm64
- name: Output manifest digest
run: |
echo "### Multi-arch Manifest Digests" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker buildx imagetools inspect calciumion/new-api:alpha >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY
docker buildx imagetools inspect ghcr.io/${GHCR_REPOSITORY}:alpha >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
+33 -10
View File
@@ -30,10 +30,11 @@ jobs:
permissions:
packages: write
contents: read
id-token: write
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }}
ref: ${{ github.event.inputs.tag || github.ref }}
@@ -59,16 +60,16 @@ jobs:
# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# - name: Log in to GHCR
# uses: docker/login-action@v3
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
# with:
# registry: ghcr.io
# username: ${{ github.actor }}
@@ -76,14 +77,15 @@ jobs:
- name: Extract metadata (labels)
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: |
calciumion/new-api
# ghcr.io/${{ env.GHCR_REPOSITORY }}
- name: Build & push single-arch (to both registries)
uses: docker/build-push-action@v6
id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
platforms: ${{ matrix.platform }}
@@ -96,8 +98,22 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
sbom: false
provenance: mode=max
sbom: true
- name: Install cosign
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
- name: Sign image with cosign
run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
- name: Output digest
run: |
echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
create_manifests:
name: Create multi-arch manifests (Docker Hub)
@@ -117,7 +133,7 @@ jobs:
# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- name: Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -136,9 +152,16 @@ jobs:
calciumion/new-api:latest-amd64 \
calciumion/new-api:latest-arm64
- name: Output manifest digest
run: |
echo "### Multi-arch Manifest" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker buildx imagetools inspect calciumion/new-api:${TAG} >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
# ---- GHCR ----
# - name: Log in to GHCR
# uses: docker/login-action@v3
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
# with:
# registry: ghcr.io
# username: ${{ github.actor }}
+28 -14
View File
@@ -19,14 +19,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Determine Version
run: |
VERSION=$(git describe --tags)
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: oven-sh/setup-bun@v2
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- name: Build Frontend
@@ -38,7 +38,7 @@ jobs:
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '>=1.25.1'
- name: Build Backend (amd64)
@@ -50,12 +50,16 @@ jobs:
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION
- name: Generate checksums
run: sha256sum new-api-* > checksums-linux.txt
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
new-api-*
checksums-linux.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -64,14 +68,14 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Determine Version
run: |
VERSION=$(git describe --tags)
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: oven-sh/setup-bun@v2
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- name: Build Frontend
@@ -84,18 +88,23 @@ jobs:
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '>=1.25.1'
- name: Build Backend
run: |
go mod download
go build -ldflags "-X 'new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION
- name: Generate checksums
run: shasum -a 256 new-api-macos-* > checksums-macos.txt
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: new-api-macos-*
files: |
new-api-macos-*
checksums-macos.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -107,14 +116,14 @@ jobs:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Determine Version
run: |
VERSION=$(git describe --tags)
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: oven-sh/setup-bun@v2
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- name: Build Frontend
@@ -126,17 +135,22 @@ jobs:
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '>=1.25.1'
- name: Build Backend
run: |
go mod download
go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe
- name: Generate checksums
run: sha256sum new-api-*.exe > checksums-windows.txt
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: new-api-*.exe
files: |
new-api-*.exe
checksums-windows.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3 -3
View File
@@ -1,4 +1,4 @@
FROM oven/bun:latest AS builder
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
WORKDIR /build
COPY web/package.json .
@@ -8,7 +8,7 @@ COPY ./web .
COPY ./VERSION .
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
FROM golang:alpine AS builder2
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
ENV GO111MODULE=on CGO_ENABLED=0
ARG TARGETOS
@@ -25,7 +25,7 @@ COPY . .
COPY --from=builder /build/dist ./web/dist
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
FROM debian:bookworm-slim
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \
+1 -1
View File
@@ -383,7 +383,7 @@ docker run --name new-api -d --restart always \
2. 在应用商店搜索 **New-API**
3. 一键安装
📖 [图文教程](./docs/BT.md)
📖 [图文教程](./docs/installation/BT.md)
</details>
+11 -8
View File
@@ -70,17 +70,20 @@
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank">
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
</a>
<a href="https://bda.pku.edu.cn/" target="_blank">
</a><!--
--><a href="https://github.com/iOfficeAI/AionUi/" target="_blank">
<img src="./docs/images/aionui.png" alt="Aion UI" height="80" />
</a><!--
--><a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="北京大學" height="80" />
</a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
</a><!--
--><a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="80" />
</a>
<a href="https://www.aliyun.com/" target="_blank">
</a><!--
--><a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="阿里雲" height="80" />
</a>
<a href="https://io.net/" target="_blank">
</a><!--
--><a href="https://io.net/" target="_blank">
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
</a>
</p>
+1
View File
@@ -229,6 +229,7 @@ func init() {
// Default implementation that returns the key as-is
// This will be replaced by i18n.T during i18n initialization
TranslateMessage = func(c *gin.Context, key string, args ...map[string]any) string {
c.Header("X-Translate-id", "d5e7afdfc7f03414b941f9c1e7096be9966510e7")
return key
}
}
+1 -1
View File
@@ -131,7 +131,7 @@ func initConstantEnv() {
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 64)
constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 64)
constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 128)
// MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨
constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 128)
// ForceStreamOption 覆盖请求参数,强制返回usage信息
+15 -8
View File
@@ -3,53 +3,60 @@ package common
import (
"fmt"
"os"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// LogWriterMu protects concurrent access to gin.DefaultWriter/gin.DefaultErrorWriter
// during log file rotation. Acquire RLock when reading/writing through the writers,
// acquire Lock when swapping writers and closing old files.
var LogWriterMu sync.RWMutex
func SysLog(s string) {
t := time.Now()
LogWriterMu.RLock()
_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
LogWriterMu.RUnlock()
}
func SysError(s string) {
t := time.Now()
LogWriterMu.RLock()
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
LogWriterMu.RUnlock()
}
func FatalLog(v ...any) {
t := time.Now()
LogWriterMu.RLock()
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
LogWriterMu.RUnlock()
os.Exit(1)
}
func LogStartupSuccess(startTime time.Time, port string) {
duration := time.Since(startTime)
durationMs := duration.Milliseconds()
// Get network IPs
networkIps := GetNetworkIps()
// Print blank line for spacing
fmt.Fprintf(gin.DefaultWriter, "\n")
LogWriterMu.RLock()
defer LogWriterMu.RUnlock()
// Print the main success message
fmt.Fprintf(gin.DefaultWriter, "\n")
fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs)
fmt.Fprintf(gin.DefaultWriter, "\n")
// Skip fancy startup message in container environments
if !IsRunningInContainer() {
// Print local URL
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mLocal:\033[0m http://localhost:%s/\n", port)
}
// Print network URLs
for _, ip := range networkIps {
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port)
}
// Print blank line for spacing
fmt.Fprintf(gin.DefaultWriter, "\n")
}
+9 -5
View File
@@ -3,6 +3,7 @@ package controller
import (
"fmt"
"net/http"
"regexp"
"slices"
"strings"
"sync"
@@ -169,10 +170,7 @@ func collectPendingUpstreamModelChangesFromModels(
upstreamSet[modelName] = struct{}{}
}
ignoredSet := make(map[string]struct{})
for _, modelName := range normalizeModelNames(ignoredModels) {
ignoredSet[modelName] = struct{}{}
}
normalizedIgnoredModels := normalizeModelNames(ignoredModels)
redirectSourceSet := make(map[string]struct{}, len(modelMapping))
redirectTargetSet := make(map[string]struct{}, len(modelMapping))
@@ -193,7 +191,13 @@ func collectPendingUpstreamModelChangesFromModels(
if _, ok := coveredUpstreamSet[modelName]; ok {
return false
}
if _, ok := ignoredSet[modelName]; ok {
if lo.ContainsBy(normalizedIgnoredModels, func(ignoredModel string) bool {
if regexBody, ok := strings.CutPrefix(ignoredModel, "regex:"); ok {
matched, err := regexp.MatchString(strings.TrimSpace(regexBody), modelName)
return err == nil && matched
}
return ignoredModel == modelName
}) {
return false
}
return true
@@ -111,6 +111,18 @@ func TestCollectPendingUpstreamModelChangesFromModels_WithModelMapping(t *testin
require.Equal(t, []string{"stale-model"}, pendingRemoveModels)
}
func TestCollectPendingUpstreamModelChangesFromModels_WithIgnoredRegexPatterns(t *testing.T) {
pendingAddModels, pendingRemoveModels := collectPendingUpstreamModelChangesFromModels(
[]string{"gpt-4o"},
[]string{"gpt-4o", "claude-3-5-sonnet", "sora-video", "gpt-4.1"},
[]string{"regex:^sora-.*$", "gpt-4.1"},
nil,
)
require.Equal(t, []string{"claude-3-5-sonnet"}, pendingAddModels)
require.Equal(t, []string{}, pendingRemoveModels)
}
func TestBuildUpstreamModelUpdateTaskNotificationContent_OmitOverflowDetails(t *testing.T) {
channelSummaries := make([]upstreamModelUpdateChannelSummary, 0, 12)
for i := 0; i < 12; i++ {
+14 -21
View File
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
@@ -116,7 +117,6 @@ func GetStatus(c *gin.Context) {
"user_agreement_enabled": legalSetting.UserAgreement != "",
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
"_qn": "new-api",
}
// 根据启用状态注入可选内容
@@ -308,31 +308,24 @@ func SendPasswordResetEmail(c *gin.Context) {
})
return
}
if !model.IsEmailAlreadyTaken(email) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该邮箱地址未注册",
})
return
}
code := common.GenerateVerificationCode(0)
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
"<p>如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> %s </p>"+
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
common.ApiError(c, err)
return
if model.IsEmailAlreadyTaken(email) {
code := common.GenerateVerificationCode(0)
common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
subject := fmt.Sprintf("%s密码重置", common.SystemName)
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+
"<p>如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:<br> %s </p>"+
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("failed to send password reset email to %s: %s", email, err.Error()))
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
type PasswordResetRequest struct {
+3 -1
View File
@@ -190,7 +190,9 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider) {
}
}
common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, nil)
common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{
"action": "bind",
})
}
// findOrCreateOAuthUser finds existing user or creates new user
+183
View File
@@ -1,12 +1,18 @@
package controller
import (
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/gin-gonic/gin"
)
@@ -169,6 +175,183 @@ func ForceGC(c *gin.Context) {
})
}
// LogFileInfo 日志文件信息
type LogFileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModTime time.Time `json:"mod_time"`
}
// LogFilesResponse 日志文件列表响应
type LogFilesResponse struct {
LogDir string `json:"log_dir"`
Enabled bool `json:"enabled"`
FileCount int `json:"file_count"`
TotalSize int64 `json:"total_size"`
OldestTime *time.Time `json:"oldest_time,omitempty"`
NewestTime *time.Time `json:"newest_time,omitempty"`
Files []LogFileInfo `json:"files"`
}
// getLogFiles 读取日志目录中的日志文件列表
func getLogFiles() ([]LogFileInfo, error) {
if *common.LogDir == "" {
return nil, nil
}
entries, err := os.ReadDir(*common.LogDir)
if err != nil {
return nil, err
}
var files []LogFileInfo
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasPrefix(name, "oneapi-") || !strings.HasSuffix(name, ".log") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
files = append(files, LogFileInfo{
Name: name,
Size: info.Size(),
ModTime: info.ModTime(),
})
}
// 按文件名降序排列(最新在前)
sort.Slice(files, func(i, j int) bool {
return files[i].Name > files[j].Name
})
return files, nil
}
// GetLogFiles 获取日志文件列表
func GetLogFiles(c *gin.Context) {
if *common.LogDir == "" {
common.ApiSuccess(c, LogFilesResponse{Enabled: false})
return
}
files, err := getLogFiles()
if err != nil {
common.ApiError(c, err)
return
}
var totalSize int64
var oldest, newest time.Time
for i, f := range files {
totalSize += f.Size
if i == 0 || f.ModTime.Before(oldest) {
oldest = f.ModTime
}
if i == 0 || f.ModTime.After(newest) {
newest = f.ModTime
}
}
resp := LogFilesResponse{
LogDir: *common.LogDir,
Enabled: true,
FileCount: len(files),
TotalSize: totalSize,
Files: files,
}
if len(files) > 0 {
resp.OldestTime = &oldest
resp.NewestTime = &newest
}
common.ApiSuccess(c, resp)
}
// CleanupLogFiles 清理过期日志文件
func CleanupLogFiles(c *gin.Context) {
mode := c.Query("mode")
valueStr := c.Query("value")
if mode != "by_count" && mode != "by_days" {
common.ApiErrorMsg(c, "invalid mode, must be by_count or by_days")
return
}
value, err := strconv.Atoi(valueStr)
if err != nil || value < 1 {
common.ApiErrorMsg(c, "invalid value, must be a positive integer")
return
}
if *common.LogDir == "" {
common.ApiErrorMsg(c, "log directory not configured")
return
}
files, err := getLogFiles()
if err != nil {
common.ApiError(c, err)
return
}
activeLogPath := logger.GetCurrentLogPath()
var toDelete []LogFileInfo
switch mode {
case "by_count":
// files 已按名称降序(最新在前),保留前 value 个
for i, f := range files {
if i < value {
continue
}
fullPath := filepath.Join(*common.LogDir, f.Name)
if fullPath == activeLogPath {
continue
}
toDelete = append(toDelete, f)
}
case "by_days":
cutoff := time.Now().AddDate(0, 0, -value)
for _, f := range files {
if f.ModTime.Before(cutoff) {
fullPath := filepath.Join(*common.LogDir, f.Name)
if fullPath == activeLogPath {
continue
}
toDelete = append(toDelete, f)
}
}
}
var deletedCount int
var freedBytes int64
var failedFiles []string
for _, f := range toDelete {
fullPath := filepath.Join(*common.LogDir, f.Name)
if err := os.Remove(fullPath); err != nil {
failedFiles = append(failedFiles, f.Name)
continue
}
deletedCount++
freedBytes += f.Size
}
result := gin.H{
"deleted_count": deletedCount,
"freed_bytes": freedBytes,
"failed_files": failedFiles,
}
if len(failedFiles) > 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("部分文件删除失败(%d/%d", len(failedFiles), len(toDelete)),
"data": result,
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": result,
})
}
// getDiskCacheInfo 获取磁盘缓存目录信息
func getDiskCacheInfo() DiskCacheInfo {
// 使用统一的缓存目录
+1 -1
View File
@@ -46,7 +46,7 @@ func GetPricing(c *gin.Context) {
"usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
"auto_groups": service.GetUserAutoGroup(group),
"_": "a42d372ccf0b5dd13ecf71203521f9d2",
"pricing_version": "a42d372ccf0b5dd13ecf71203521f9d2",
})
}
+12 -2
View File
@@ -925,9 +925,19 @@ func ManageUser(c *gin.Context) {
return
}
type emailBindRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
func EmailBind(c *gin.Context) {
email := c.Query("email")
code := c.Query("code")
var req emailBindRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
common.ApiError(c, errors.New("invalid request body"))
return
}
email := req.Email
code := req.Code
if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) {
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
return
+9
View File
@@ -10,10 +10,12 @@ import (
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
)
@@ -127,6 +129,13 @@ func VideoProxy(c *gin.Context) {
return
}
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(videoURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL blocked for task %s: %v", taskID, err))
videoProxyError(c, http.StatusForbidden, "server_error", fmt.Sprintf("request blocked: %v", err))
return
}
req.URL, err = url.Parse(videoURL)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", videoURL, err.Error()))
+15 -2
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
@@ -25,7 +26,7 @@ func getWeChatIdByCode(code string) (string, error) {
if code == "" {
return "", errors.New("无效的参数")
}
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, code), nil)
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, url.QueryEscape(code)), nil)
if err != nil {
return "", err
}
@@ -121,6 +122,10 @@ func WeChatAuth(c *gin.Context) {
setupLogin(&user, c)
}
type wechatBindRequest struct {
Code string `json:"code"`
}
func WeChatBind(c *gin.Context) {
if !common.WeChatAuthEnabled {
c.JSON(http.StatusOK, gin.H{
@@ -129,7 +134,15 @@ func WeChatBind(c *gin.Context) {
})
return
}
code := c.Query("code")
var req wechatBindRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的请求",
})
return
}
code := req.Code
wechatId, err := getWeChatIdByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
+150 -2
View File
@@ -1,3 +1,151 @@
密钥为环境变量SESSION_SECRET
# 宝塔面板部署教程
本文档提供使用宝塔面板 Docker 功能部署 New API 的图文教程。
> 📖 官方文档:[宝塔面板部署](https://docs.newapi.pro/zh/docs/installation/deployment-methods/bt-docker-installation)
***
## 前置要求
| 项目 | 要求 |
| ----- | ---------------------------------- |
| 宝塔面板 | ≥ 9.2.0 版本 |
| 推荐系统 | CentOS 7+、Ubuntu 18.04+、Debian 10+ |
| 服务器配置 | 至少 1 核 2G 内存 |
***
## 步骤一:安装宝塔面板
1. 前往 [宝塔面板官网](https://www.bt.cn/new/download.html) 下载适合您系统的安装脚本
2. 运行安装脚本安装宝塔面板
3. 安装完成后,使用提供的地址、用户名和密码登录宝塔面板
***
## 步骤二:安装 Docker
1. 登录宝塔面板后,在左侧菜单栏找到并点击 **Docker**
2. 首次进入会提示安装 Docker 服务,点击 **立即安装**
3. 按照提示完成 Docker 服务的安装
***
## 步骤三:安装 New API
### 方法一:使用宝塔应用商店(推荐)
1. 在宝塔面板 Docker 功能中,点击 **应用商店**
2. 搜索并找到 **New-API**
3. 点击 **安装**
4. 配置以下基本选项:
- **容器名称**:可自定义,默认为 `new-api`
- **端口映射**:默认为 `3000:3000`
- **环境变量**
- `SESSION_SECRET`:会话密钥(**必填**,多机部署时必须一致)
- `CRYPTO_SECRET`:加密密钥(使用 Redis 时必填)
5. 点击 **确认** 开始安装
6. 等待安装完成后,访问 `http://您的服务器IP:3000` 即可使用
### 方法二:使用 Docker Compose
1. 在宝塔面板中创建网站目录,如 `/www/wwwroot/new-api`
2. 创建 `docker-compose.yml` 文件:
```yaml
version: '3'
services:
new-api:
image: calciumion/new-api:latest
container_name: new-api
restart: always
ports:
- "3000:3000"
volumes:
- ./data:/data
environment:
- SESSION_SECRET=your_session_secret_here # 请修改为随机字符串
- TZ=Asia/Shanghai
```
1. 在终端中进入目录并启动:
```bash
cd /www/wwwroot/new-api
docker-compose up -d
```
***
## 配置说明
### 必要环境变量
| 变量名 | 说明 | 是否必填 |
| ------------------- | ------------------ | ------ |
| `SESSION_SECRET` | 会话密钥,多机部署必须一致 | **必填** |
| `CRYPTO_SECRET` | 加密密钥,使用 Redis 时必填 | 条件必填 |
| `SQL_DSN` | 数据库连接字符串(使用外部数据库时) | 可选 |
| `REDIS_CONN_STRING` | Redis 连接字符串 | 可选 |
### 生成随机密钥
```bash
# 生成 SESSION_SECRET
openssl rand -hex 16
# 或使用 Linux 命令
head -c 16 /dev/urandom | xxd -p
```
***
## 常见问题
### Q1:无法访问 3000 端口?
1. 检查服务器防火墙是否开放 3000 端口
2. 在宝塔面板 **安全** 中放行 3000 端口
3. 检查云服务器安全组是否开放端口
### Q2:登录后提示会话失效?
确保设置了 `SESSION_SECRET` 环境变量,且值不为空。
### Q3:数据如何持久化?
使用 Docker 卷映射数据目录:
```yaml
volumes:
- ./data:/data
```
### Q4:如何更新版本?
```bash
# 拉取最新镜像
docker pull calciumion/new-api:latest
# 重启容器
docker-compose down && docker-compose up -d
```
***
## 相关链接
- [官方文档](https://docs.newapi.pro/zh/docs/installation)
- [环境变量配置](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables)
- [常见问题](https://docs.newapi.pro/zh/docs/support/faq)
- [GitHub 仓库](https://github.com/QuantumNous/new-api)
***
## 截图示例
![宝塔面板 Docker 安装](https://github.com/user-attachments/assets/7a6fc03e-c457-45e4-b8f9-184508fc26b0)
> ⚠️ 注意:密钥为环境变量 `SESSION_SECRET`,请务必设置!
![8285bba413e770fe9620f1bf9b40d44e](https://github.com/user-attachments/assets/7a6fc03e-c457-45e4-b8f9-184508fc26b0)
+5 -6
View File
@@ -148,15 +148,14 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
}
}
// not support token count for dalle
n := uint(1)
if i.N != nil {
n = *i.N
}
// n is NOT included here; it is handled via OtherRatio("n") in
// image_handler.go (default) or channel adaptors (actual count).
// Including n here caused double-counting for channels that also
// set OtherRatio("n") (e.g. Ali/Bailian).
return &types.TokenCountMeta{
CombineText: i.Prompt,
MaxTokens: 1584,
ImagePriceRatio: sizeRatio * qualityRatio * float64(n),
ImagePriceRatio: sizeRatio * qualityRatio,
}
}
+1 -1
View File
@@ -393,7 +393,7 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl {
type MessageImageUrl struct {
Url string `json:"url"`
Detail string `json:"detail"`
Detail string `json:"detail,omitempty"`
MimeType string
}
+7 -5
View File
@@ -220,10 +220,12 @@ type CompletionsStreamResponse struct {
}
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
UsageSemantic string `json:"usage_semantic,omitempty"`
UsageSource string `json:"usage_source,omitempty"`
PromptTokensDetails InputTokenDetails `json:"prompt_tokens_details"`
CompletionTokenDetails OutputTokenDetails `json:"completion_tokens_details"`
@@ -251,7 +253,7 @@ type OpenAIVideoResponse struct {
type InputTokenDetails struct {
CachedTokens int `json:"cached_tokens"`
CachedCreationTokens int `json:"-"`
CachedCreationTokens int `json:"cached_creation_tokens,omitempty"`
TextTokens int `json:"text_tokens"`
AudioTokens int `json:"audio_tokens"`
ImageTokens int `json:"image_tokens"`
Generated Vendored
+3 -3
View File
@@ -3948,9 +3948,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
+3 -3
View File
@@ -49,11 +49,11 @@ require (
github.com/waffo-com/waffo-go v1.3.1
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
golang.org/x/crypto v0.45.0
golang.org/x/image v0.23.0
golang.org/x/image v0.38.0
golang.org/x/net v0.47.0
golang.org/x/sync v0.19.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.38.0
golang.org/x/text v0.32.0
golang.org/x/text v0.35.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.4.3
gorm.io/driver/postgres v1.5.2
+10 -10
View File
@@ -325,16 +325,16 @@ golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -353,11 +353,11 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+26 -4
View File
@@ -29,6 +29,15 @@ const maxLogCount = 1000000
var logCount int
var setupLogLock sync.Mutex
var setupLogWorking bool
var currentLogPath string
var currentLogPathMu sync.RWMutex
var currentLogFile *os.File
func GetCurrentLogPath() string {
currentLogPathMu.RLock()
defer currentLogPathMu.RUnlock()
return currentLogPath
}
func SetupLogger() {
defer func() {
@@ -48,8 +57,19 @@ func SetupLogger() {
if err != nil {
log.Fatal("failed to open log file")
}
currentLogPathMu.Lock()
oldFile := currentLogFile
currentLogPath = logPath
currentLogFile = fd
currentLogPathMu.Unlock()
common.LogWriterMu.Lock()
gin.DefaultWriter = io.MultiWriter(os.Stdout, fd)
gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, fd)
if oldFile != nil {
_ = oldFile.Close()
}
common.LogWriterMu.Unlock()
}
}
@@ -75,16 +95,18 @@ func LogDebug(ctx context.Context, msg string, args ...any) {
}
func logHelper(ctx context.Context, level string, msg string) {
writer := gin.DefaultErrorWriter
if level == loggerINFO {
writer = gin.DefaultWriter
}
id := ctx.Value(common.RequestIdKey)
if id == nil {
id = "SYSTEM"
}
now := time.Now()
common.LogWriterMu.RLock()
writer := gin.DefaultErrorWriter
if level == loggerINFO {
writer = gin.DefaultWriter
}
_, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg)
common.LogWriterMu.RUnlock()
logCount++ // we don't need accurate count, so no lock here
if logCount > maxLogCount && !setupLogWorking {
logCount = 0
+7 -2
View File
@@ -101,8 +101,13 @@ func Distribute() func(c *gin.Context) {
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
preferred, err := model.CacheGetChannel(preferredChannelID)
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
if usingGroup == "auto" {
if err == nil && preferred != nil {
if preferred.Status != common.ChannelStatusEnabled {
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorChannelDisabled))
return
}
} else if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup)
for _, g := range autoGroups {
+2 -1
View File
@@ -58,7 +58,8 @@ func formatUserLogs(logs []*Log, startIdx int) {
if otherMap != nil {
// Remove admin-only debug fields.
delete(otherMap, "admin_info")
delete(otherMap, "reject_reason")
// delete(otherMap, "reject_reason")
delete(otherMap, "stream_status")
}
logs[i].Other = common.MapToJsonStr(otherMap)
logs[i].Id = startIdx + i + 1
+9 -4
View File
@@ -208,10 +208,7 @@ func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToke
}
// Set authorization header
tokenType := token.TokenType
if tokenType == "" {
tokenType = "Bearer"
}
tokenType := normalizeAuthorizationTokenType(token.TokenType)
req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenType, token.AccessToken))
req.Header.Set("Accept", "application/json")
@@ -320,6 +317,14 @@ func (p *GenericOAuthProvider) GetProviderId() int {
return p.config.Id
}
func normalizeAuthorizationTokenType(tokenType string) string {
tokenType = strings.TrimSpace(tokenType)
if tokenType == "" || strings.EqualFold(tokenType, "Bearer") {
return "Bearer"
}
return tokenType
}
// IsGenericProvider returns true for generic providers
func (p *GenericOAuthProvider) IsGenericProvider() bool {
return true
+1 -1
View File
@@ -70,7 +70,7 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
if usage.(*dto.Usage).CompletionTokenDetails.AudioTokens > 0 || usage.(*dto.Usage).PromptTokensDetails.AudioTokens > 0 {
service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "")
} else {
postConsumeQuota(c, info, usage.(*dto.Usage))
service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), nil)
}
return nil
+11 -6
View File
@@ -171,12 +171,17 @@ type AliImageRequest struct {
}
type AliImageParameters struct {
Size string `json:"size,omitempty"`
N int `json:"n,omitempty"`
Steps string `json:"steps,omitempty"`
Scale string `json:"scale,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
PromptExtend *bool `json:"prompt_extend,omitempty"`
Size string `json:"size,omitempty"`
N int `json:"n,omitempty"`
Steps string `json:"steps,omitempty"`
Scale string `json:"scale,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
PromptExtend *bool `json:"prompt_extend,omitempty"`
ThinkingMode *bool `json:"thinking_mode,omitempty"`
EnableSequential *bool `json:"enable_sequential,omitempty"`
BboxList any `json:"bbox_list,omitempty"`
ColorPalette any `json:"color_palette,omitempty"`
Seed *int `json:"seed,omitempty"`
}
func (p *AliImageParameters) PromptExtendValue() bool {
+1 -2
View File
@@ -54,7 +54,6 @@ func oaiImage2AliImageRequest(info *relaycommon.RelayInfo, request dto.ImageRequ
}
}
// 检查n参数
if imageRequest.Parameters.N != 0 {
info.PriceData.AddOtherRatio("n", float64(imageRequest.Parameters.N))
}
@@ -181,6 +180,7 @@ func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, reque
},
}
imageRequest.Parameters = AliImageParameters{
N: int(lo.FromPtrOr(request.N, uint(1))),
Watermark: request.Watermark,
}
return &imageRequest, nil
@@ -328,7 +328,6 @@ func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *rela
}
imageResponses := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat)
// 可能生成多张图片,修正计费数量n
if aliResponse.Usage.ImageCount != 0 {
info.PriceData.AddOtherRatio("n", float64(aliResponse.Usage.ImageCount))
} else if len(imageResponses.Data) != 0 {
+2 -1
View File
@@ -40,7 +40,8 @@ func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, requ
}
func isOldWanModel(modelName string) bool {
return strings.Contains(modelName, "wan") && !strings.Contains(modelName, "wan2.6")
return strings.Contains(modelName, "wan") &&
!lo.SomeBy([]string{"wan2.6", "wan2.7"}, func(v string) bool { return strings.Contains(modelName, v) })
}
func isWanModel(modelName string) bool {
+6 -7
View File
@@ -116,12 +116,12 @@ func embeddingResponseBaidu2OpenAI(response *BaiduEmbeddingResponse) *dto.OpenAI
func baiduStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
usage := &dto.Usage{}
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var baiduResponse BaiduChatStreamResponse
err := common.Unmarshal([]byte(data), &baiduResponse)
if err != nil {
if err := common.Unmarshal([]byte(data), &baiduResponse); err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
return true
sr.Error(err)
return
}
if baiduResponse.Usage.TotalTokens != 0 {
usage.TotalTokens = baiduResponse.Usage.TotalTokens
@@ -129,11 +129,10 @@ func baiduStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
usage.CompletionTokens = baiduResponse.Usage.TotalTokens - baiduResponse.Usage.PromptTokens
}
response := streamResponseBaidu2OpenAI(&baiduResponse)
err = helper.ObjectData(c, response)
if err != nil {
if err := helper.ObjectData(c, response); err != nil {
common.SysLog("error sending stream response: " + err.Error())
sr.Error(err)
}
return true
})
service.CloseResponseBodyGracefully(resp)
return nil, usage
+26 -4
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
@@ -41,11 +42,32 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
baseURL := fmt.Sprintf("%s/v1/messages", info.ChannelBaseUrl)
if info.IsClaudeBetaQuery {
baseURL = baseURL + "?beta=true"
requestURL := fmt.Sprintf("%s/v1/messages", info.ChannelBaseUrl)
if !shouldAppendClaudeBetaQuery(info) {
return requestURL, nil
}
return baseURL, nil
parsedURL, err := url.Parse(requestURL)
if err != nil {
return "", err
}
query := parsedURL.Query()
query.Set("beta", "true")
parsedURL.RawQuery = query.Encode()
return parsedURL.String(), nil
}
func shouldAppendClaudeBetaQuery(info *relaycommon.RelayInfo) bool {
if info == nil {
return false
}
if info.IsClaudeBetaQuery {
return true
}
if info.ChannelOtherSettings.ClaudeBetaQuery {
return true
}
return false
}
func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) {
+40 -5
View File
@@ -555,6 +555,35 @@ type ClaudeResponseInfo struct {
Done bool
}
func cacheCreationTokensForOpenAIUsage(usage *dto.Usage) int {
if usage == nil {
return 0
}
splitCacheCreationTokens := usage.ClaudeCacheCreation5mTokens + usage.ClaudeCacheCreation1hTokens
if splitCacheCreationTokens == 0 {
return usage.PromptTokensDetails.CachedCreationTokens
}
if usage.PromptTokensDetails.CachedCreationTokens > splitCacheCreationTokens {
return usage.PromptTokensDetails.CachedCreationTokens
}
return splitCacheCreationTokens
}
func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
if usage == nil {
return dto.Usage{}
}
clone := *usage
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
clone.PromptTokens = totalInputTokens
clone.InputTokens = totalInputTokens
clone.TotalTokens = totalInputTokens + usage.CompletionTokens
clone.UsageSemantic = "openai"
clone.UsageSource = "anthropic"
return clone
}
func buildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo *ClaudeResponseInfo) *dto.ClaudeUsage {
usage := &dto.ClaudeUsage{}
if claudeResponse != nil && claudeResponse.Usage != nil {
@@ -643,6 +672,7 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d
// message_start, 获取usage
if claudeResponse.Message != nil && claudeResponse.Message.Usage != nil {
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
claudeInfo.Usage.UsageSemantic = "anthropic"
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
@@ -661,6 +691,7 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d
} else if claudeResponse.Type == "message_delta" {
// 最终的usage获取
if claudeResponse.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic"
if claudeResponse.Usage.InputTokens > 0 {
// 不叠加,只取最新的
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
@@ -754,12 +785,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
}
claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
}
if claudeInfo.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic"
}
if info.RelayFormat == types.RelayFormatClaude {
//
} else if info.RelayFormat == types.RelayFormatOpenAI {
if info.ShouldIncludeUsage {
response := helper.GenerateFinalUsageResponse(claudeInfo.ResponseId, claudeInfo.Created, info.UpstreamModelName, *claudeInfo.Usage)
openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(claudeInfo.Usage)
response := helper.GenerateFinalUsageResponse(claudeInfo.ResponseId, claudeInfo.Created, info.UpstreamModelName, openAIUsage)
err := helper.ObjectData(c, response)
if err != nil {
common.SysLog("send final response failed: " + err.Error())
@@ -778,12 +813,11 @@ func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.
Usage: &dto.Usage{},
}
var err *types.NewAPIError
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
err = HandleStreamResponseData(c, info, claudeInfo, data)
if err != nil {
return false
sr.Stop(err)
}
return true
})
if err != nil {
return nil, err
@@ -810,6 +844,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
claudeInfo.Usage.UsageSemantic = "anthropic"
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
@@ -819,7 +854,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
switch info.RelayFormat {
case types.RelayFormatOpenAI:
openaiResponse := ResponseClaude2OpenAI(&claudeResponse)
openaiResponse.Usage = *claudeInfo.Usage
openaiResponse.Usage = buildOpenAIStyleUsageFromClaudeUsage(claudeInfo.Usage)
responseData, err = json.Marshal(openaiResponse)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody)
+82
View File
@@ -173,3 +173,85 @@ func TestFormatClaudeResponseInfo_ContentBlockDelta(t *testing.T) {
t.Errorf("ResponseText = %q, want %q", claudeInfo.ResponseText.String(), "hello")
}
}
func TestBuildOpenAIStyleUsageFromClaudeUsage(t *testing.T) {
usage := &dto.Usage{
PromptTokens: 100,
CompletionTokens: 20,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 30,
CachedCreationTokens: 50,
},
ClaudeCacheCreation5mTokens: 10,
ClaudeCacheCreation1hTokens: 20,
UsageSemantic: "anthropic",
}
openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(usage)
if openAIUsage.PromptTokens != 180 {
t.Fatalf("PromptTokens = %d, want 180", openAIUsage.PromptTokens)
}
if openAIUsage.InputTokens != 180 {
t.Fatalf("InputTokens = %d, want 180", openAIUsage.InputTokens)
}
if openAIUsage.TotalTokens != 200 {
t.Fatalf("TotalTokens = %d, want 200", openAIUsage.TotalTokens)
}
if openAIUsage.UsageSemantic != "openai" {
t.Fatalf("UsageSemantic = %s, want openai", openAIUsage.UsageSemantic)
}
if openAIUsage.UsageSource != "anthropic" {
t.Fatalf("UsageSource = %s, want anthropic", openAIUsage.UsageSource)
}
}
func TestBuildOpenAIStyleUsageFromClaudeUsagePreservesCacheCreationRemainder(t *testing.T) {
tests := []struct {
name string
cachedCreationTokens int
cacheCreationTokens5m int
cacheCreationTokens1h int
expectedTotalInputToken int
}{
{
name: "prefers aggregate when it includes remainder",
cachedCreationTokens: 50,
cacheCreationTokens5m: 10,
cacheCreationTokens1h: 20,
expectedTotalInputToken: 180,
},
{
name: "falls back to split tokens when aggregate missing",
cachedCreationTokens: 0,
cacheCreationTokens5m: 10,
cacheCreationTokens1h: 20,
expectedTotalInputToken: 160,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
usage := &dto.Usage{
PromptTokens: 100,
CompletionTokens: 20,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 30,
CachedCreationTokens: tt.cachedCreationTokens,
},
ClaudeCacheCreation5mTokens: tt.cacheCreationTokens5m,
ClaudeCacheCreation1hTokens: tt.cacheCreationTokens1h,
UsageSemantic: "anthropic",
}
openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(usage)
if openAIUsage.PromptTokens != tt.expectedTotalInputToken {
t.Fatalf("PromptTokens = %d, want %d", openAIUsage.PromptTokens, tt.expectedTotalInputToken)
}
if openAIUsage.InputTokens != tt.expectedTotalInputToken {
t.Fatalf("InputTokens = %d, want %d", openAIUsage.InputTokens, tt.expectedTotalInputToken)
}
})
}
}
+16 -17
View File
@@ -223,33 +223,32 @@ func difyStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
usage := &dto.Usage{}
var nodeToken int
helper.SetEventStreamHeaders(c)
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var difyResponse DifyChunkChatCompletionResponse
err := json.Unmarshal([]byte(data), &difyResponse)
if err != nil {
if err := json.Unmarshal([]byte(data), &difyResponse); err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
return true
sr.Error(err)
return
}
var openaiResponse dto.ChatCompletionsStreamResponse
if difyResponse.Event == "message_end" {
usage = &difyResponse.MetaData.Usage
return false
sr.Done()
return
} else if difyResponse.Event == "error" {
return false
} else {
openaiResponse = *streamResponseDify2OpenAI(difyResponse)
if len(openaiResponse.Choices) != 0 {
responseText += openaiResponse.Choices[0].Delta.GetContentString()
if openaiResponse.Choices[0].Delta.ReasoningContent != nil {
nodeToken += 1
}
sr.Stop(fmt.Errorf("dify error event"))
return
}
openaiResponse := *streamResponseDify2OpenAI(difyResponse)
if len(openaiResponse.Choices) != 0 {
responseText += openaiResponse.Choices[0].Delta.GetContentString()
if openaiResponse.Choices[0].Delta.ReasoningContent != nil {
nodeToken += 1
}
}
err = helper.ObjectData(c, openaiResponse)
if err != nil {
if err := helper.ObjectData(c, openaiResponse); err != nil {
common.SysLog(err.Error())
sr.Error(err)
}
return true
})
helper.Done(c)
if usage.TotalTokens == 0 {
+7 -6
View File
@@ -1297,12 +1297,11 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
var imageCount int
responseText := strings.Builder{}
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var geminiResponse dto.GeminiChatResponse
err := common.UnmarshalJsonStr(data, &geminiResponse)
if err != nil {
logger.LogError(c, "error unmarshalling stream response: "+err.Error())
return false
if err := common.UnmarshalJsonStr(data, &geminiResponse); err != nil {
sr.Stop(fmt.Errorf("unmarshal: %w", err))
return
}
if len(geminiResponse.Candidates) == 0 && geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
@@ -1327,7 +1326,9 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
*usage = mappedUsage
}
return callback(data, &geminiResponse)
if !callback(data, &geminiResponse) {
sr.Stop(fmt.Errorf("gemini callback stopped"))
}
})
if imageCount != 0 {
+7 -7
View File
@@ -35,21 +35,21 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
c.Writer.WriteHeader(resp.StatusCode)
if info.IsStream {
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
if service.SundaySearch(data, "usage") {
var simpleResponse dto.SimpleResponse
err := common.Unmarshal([]byte(data), &simpleResponse)
if err != nil {
if err := common.Unmarshal([]byte(data), &simpleResponse); err != nil {
logger.LogError(c, err.Error())
}
if simpleResponse.Usage.TotalTokens != 0 {
sr.Error(err)
} else if simpleResponse.Usage.TotalTokens != 0 {
usage.PromptTokens = simpleResponse.Usage.InputTokens
usage.CompletionTokens = simpleResponse.OutputTokens
usage.TotalTokens = simpleResponse.TotalTokens
}
}
_ = helper.StringData(c, data)
return true
if err := helper.StringData(c, data); err != nil {
sr.Error(err)
}
})
} else {
common.SetContextKey(c, constant.ContextKeyLocalCountTokens, true)
+27 -16
View File
@@ -296,15 +296,17 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return true
}
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
if streamErr != nil {
return false
sr.Stop(streamErr)
return
}
var streamResp dto.ResponsesStreamResponse
if err := common.UnmarshalJsonStr(data, &streamResp); err != nil {
logger.LogError(c, "failed to unmarshal responses stream event: "+err.Error())
return true
sr.Error(err)
return
}
switch streamResp.Type {
@@ -320,14 +322,16 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
//case "response.reasoning_text.delta":
//if !sendReasoningDelta(streamResp.Delta) {
// return false
// sr.Stop(streamErr)
// return
//}
//case "response.reasoning_text.done":
case "response.reasoning_summary_text.delta":
if !sendReasoningSummaryDelta(streamResp.Delta) {
return false
sr.Stop(streamErr)
return
}
case "response.reasoning_summary_text.done":
@@ -349,12 +353,14 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
// delta := stringDeltaFromPrefix(prev, next)
// reasoningSummaryTextByKey[key] = next
// if !sendReasoningSummaryDelta(delta) {
// return false
// sr.Stop(streamErr)
// return
// }
case "response.output_text.delta":
if !sendStartIfNeeded() {
return false
sr.Stop(streamErr)
return
}
if streamResp.Delta != "" {
@@ -376,7 +382,8 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
},
}
if !sendChatChunk(chunk) {
return false
sr.Stop(streamErr)
return
}
}
@@ -414,7 +421,8 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
}
if !sendToolCallDelta(callID, name, argsDelta) {
return false
sr.Stop(streamErr)
return
}
case "response.function_call_arguments.delta":
@@ -428,7 +436,8 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
}
toolCallArgsByID[callID] += streamResp.Delta
if !sendToolCallDelta(callID, "", streamResp.Delta) {
return false
sr.Stop(streamErr)
return
}
case "response.function_call_arguments.done":
@@ -467,7 +476,8 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
}
if !sendStartIfNeeded() {
return false
sr.Stop(streamErr)
return
}
if !sentStop {
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
@@ -479,7 +489,8 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
}
stop := helper.GenerateStopResponse(responseId, createAt, model, finishReason)
if !sendChatChunk(stop) {
return false
sr.Stop(streamErr)
return
}
sentStop = true
}
@@ -488,16 +499,16 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
if streamResp.Response != nil {
if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" {
streamErr = types.WithOpenAIError(*oaiErr, http.StatusInternalServerError)
return false
sr.Stop(streamErr)
return
}
}
streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
sr.Stop(streamErr)
return
default:
}
return true
})
if streamErr != nil {
+31 -4
View File
@@ -126,11 +126,11 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
// 检查是否为音频模型
isAudioModel := strings.Contains(strings.ToLower(model), "audio")
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
if lastStreamData != "" {
err := HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent)
if err != nil {
if err := HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent); err != nil {
common.SysLog("error handling stream format: " + err.Error())
sr.Error(err)
}
}
if len(data) > 0 {
@@ -142,7 +142,6 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
lastStreamData = data
streamItems = append(streamItems, data)
}
return true
})
// 对音频模型,从倒数第二个stream data中提取usage信息
@@ -627,6 +626,12 @@ func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, res
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
}
}
case constant.ChannelTypeOpenAI:
if usage.PromptTokensDetails.CachedTokens == 0 {
if cachedTokens, ok := extractLlamaCachedTokensFromBody(responseBody); ok {
usage.PromptTokensDetails.CachedTokens = cachedTokens
}
}
}
}
@@ -689,3 +694,25 @@ func extractMoonshotCachedTokensFromBody(body []byte) (int, bool) {
return 0, false
}
// extractLlamaCachedTokensFromBody 从llama.cpp的非标准位置提取cache_n
func extractLlamaCachedTokensFromBody(body []byte) (int, bool) {
if len(body) == 0 {
return 0, false
}
var payload struct {
Timings struct {
CachedTokens *int `json:"cache_n"`
} `json:"timings"`
}
if err := common.Unmarshal(body, &payload); err != nil {
return 0, false
}
if payload.Timings.CachedTokens == nil {
return 0, false
}
return *payload.Timings.CachedTokens, true
}
+38 -38
View File
@@ -79,55 +79,55 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
var usage = &dto.Usage{}
var responseTextBuilder strings.Builder
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
// 检查当前数据是否包含 completed 状态和 usage 信息
var streamResponse dto.ResponsesStreamResponse
if err := common.UnmarshalJsonStr(data, &streamResponse); err == nil {
sendResponsesStreamData(c, streamResponse, data)
switch streamResponse.Type {
case "response.completed":
if streamResponse.Response != nil {
if streamResponse.Response.Usage != nil {
if streamResponse.Response.Usage.InputTokens != 0 {
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
}
if streamResponse.Response.Usage.OutputTokens != 0 {
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
}
if streamResponse.Response.Usage.TotalTokens != 0 {
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
}
if streamResponse.Response.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
}
if err := common.UnmarshalJsonStr(data, &streamResponse); err != nil {
logger.LogError(c, "failed to unmarshal stream response: "+err.Error())
sr.Error(err)
return
}
sendResponsesStreamData(c, streamResponse, data)
switch streamResponse.Type {
case "response.completed":
if streamResponse.Response != nil {
if streamResponse.Response.Usage != nil {
if streamResponse.Response.Usage.InputTokens != 0 {
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
}
if streamResponse.Response.HasImageGenerationCall() {
c.Set("image_generation_call", true)
c.Set("image_generation_call_quality", streamResponse.Response.GetQuality())
c.Set("image_generation_call_size", streamResponse.Response.GetSize())
if streamResponse.Response.Usage.OutputTokens != 0 {
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
}
if streamResponse.Response.Usage.TotalTokens != 0 {
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
}
if streamResponse.Response.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
}
}
case "response.output_text.delta":
// 处理输出文本
responseTextBuilder.WriteString(streamResponse.Delta)
case dto.ResponsesOutputTypeItemDone:
// 函数调用处理
if streamResponse.Item != nil {
switch streamResponse.Item.Type {
case dto.BuildInCallWebSearchCall:
if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil {
if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil {
webSearchTool.CallCount++
}
if streamResponse.Response.HasImageGenerationCall() {
c.Set("image_generation_call", true)
c.Set("image_generation_call_quality", streamResponse.Response.GetQuality())
c.Set("image_generation_call_size", streamResponse.Response.GetSize())
}
}
case "response.output_text.delta":
// 处理输出文本
responseTextBuilder.WriteString(streamResponse.Delta)
case dto.ResponsesOutputTypeItemDone:
// 函数调用处理
if streamResponse.Item != nil {
switch streamResponse.Item.Type {
case dto.BuildInCallWebSearchCall:
if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil {
if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil {
webSearchTool.CallCount++
}
}
}
}
} else {
logger.LogError(c, "failed to unmarshal stream response: "+err.Error())
}
return true
})
if usage.CompletionTokens == 0 {
+2
View File
@@ -17,6 +17,8 @@ func UnmarshalMetadata(metadata map[string]any, target any) error {
if metadata == nil {
return nil
}
// Prevent metadata from overriding model fields to avoid billing bypass.
delete(metadata, "model")
metaBytes, err := common.Marshal(metadata)
if err != nil {
return fmt.Errorf("marshal metadata failed: %w", err)
+1 -1
View File
@@ -76,7 +76,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if strings.HasPrefix(request.Model, "grok-3-mini") {
if lo.FromPtrOr(request.MaxCompletionTokens, uint(0)) == 0 && lo.FromPtrOr(request.MaxTokens, uint(0)) != 0 {
request.MaxCompletionTokens = request.MaxTokens
request.MaxTokens = lo.ToPtr(uint(0))
request.MaxTokens = nil
}
if strings.HasSuffix(request.Model, "-high") {
request.ReasoningEffort = "high"
+6 -7
View File
@@ -43,12 +43,12 @@ func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
helper.SetEventStreamHeaders(c)
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var xAIResp *dto.ChatCompletionsStreamResponse
err := common.UnmarshalJsonStr(data, &xAIResp)
if err != nil {
if err := common.UnmarshalJsonStr(data, &xAIResp); err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
return true
sr.Error(err)
return
}
// 把 xAI 的usage转换为 OpenAI 的usage
@@ -61,11 +61,10 @@ func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
openaiResponse := streamResponseXAI2OpenAI(xAIResp, usage)
_ = openai.ProcessStreamResponse(*openaiResponse, &responseTextBuilder, &toolCount)
err = helper.ObjectData(c, openaiResponse)
if err != nil {
if err := helper.ObjectData(c, openaiResponse); err != nil {
common.SysLog(err.Error())
sr.Error(err)
}
return true
})
if !containStreamUsage {
+2 -2
View File
@@ -122,7 +122,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
return newApiErr
}
service.PostClaudeConsumeQuota(c, info, usage)
service.PostTextConsumeQuota(c, info, usage, nil)
return nil
}
@@ -190,6 +190,6 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
return newAPIError
}
service.PostClaudeConsumeQuota(c, info, usage.(*dto.Usage))
service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), nil)
return nil
}
+3 -6
View File
@@ -162,6 +162,8 @@ type RelayInfo struct {
// 若为空,调用 GetFinalRequestRelayFormat 会回退到 RequestConversionChain 的最后一项或 RelayFormat。
FinalRequestRelayFormat types.RelayFormat
StreamStatus *StreamStatus
ThinkingContentInfo
TokenCountMeta
*ClaudeConvertInfo
@@ -338,15 +340,10 @@ func GenRelayInfoClaude(c *gin.Context, request dto.Request) *RelayInfo {
info.ClaudeConvertInfo = &ClaudeConvertInfo{
LastMessagesType: LastMessageTypeNone,
}
info.IsClaudeBetaQuery = c.Query("beta") == "true" || isClaudeBetaForced(c)
info.IsClaudeBetaQuery = c.Query("beta") == "true"
return info
}
func isClaudeBetaForced(c *gin.Context) bool {
channelOtherSettings, ok := common.GetContextKeyType[dto.ChannelOtherSettings](c, constant.ContextKeyChannelOtherSetting)
return ok && channelOtherSettings.ClaudeBetaQuery
}
func GenRelayInfoRerank(c *gin.Context, request *dto.RerankRequest) *RelayInfo {
info := genBaseRelayInfo(c, request)
info.RelayMode = relayconstant.RelayModeRerank
+112
View File
@@ -0,0 +1,112 @@
package common
import (
"fmt"
"strings"
"sync"
"time"
)
type StreamEndReason string
const (
StreamEndReasonNone StreamEndReason = ""
StreamEndReasonDone StreamEndReason = "done"
StreamEndReasonTimeout StreamEndReason = "timeout"
StreamEndReasonClientGone StreamEndReason = "client_gone"
StreamEndReasonScannerErr StreamEndReason = "scanner_error"
StreamEndReasonHandlerStop StreamEndReason = "handler_stop"
StreamEndReasonEOF StreamEndReason = "eof"
StreamEndReasonPanic StreamEndReason = "panic"
StreamEndReasonPingFail StreamEndReason = "ping_fail"
)
const maxStreamErrorEntries = 20
type StreamErrorEntry struct {
Message string
Timestamp time.Time
}
type StreamStatus struct {
EndReason StreamEndReason
EndError error
endOnce sync.Once
mu sync.Mutex
Errors []StreamErrorEntry
ErrorCount int
}
func NewStreamStatus() *StreamStatus {
return &StreamStatus{}
}
func (s *StreamStatus) SetEndReason(reason StreamEndReason, err error) {
if s == nil {
return
}
s.endOnce.Do(func() {
s.EndReason = reason
s.EndError = err
})
}
func (s *StreamStatus) RecordError(msg string) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.ErrorCount++
if len(s.Errors) < maxStreamErrorEntries {
s.Errors = append(s.Errors, StreamErrorEntry{
Message: msg,
Timestamp: time.Now(),
})
}
}
func (s *StreamStatus) HasErrors() bool {
if s == nil {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
return s.ErrorCount > 0
}
func (s *StreamStatus) TotalErrorCount() int {
if s == nil {
return 0
}
s.mu.Lock()
defer s.mu.Unlock()
return s.ErrorCount
}
func (s *StreamStatus) IsNormalEnd() bool {
if s == nil {
return true
}
return s.EndReason == StreamEndReasonDone ||
s.EndReason == StreamEndReasonEOF ||
s.EndReason == StreamEndReasonHandlerStop
}
func (s *StreamStatus) Summary() string {
if s == nil {
return "StreamStatus<nil>"
}
b := &strings.Builder{}
fmt.Fprintf(b, "reason=%s", s.EndReason)
if s.EndError != nil {
fmt.Fprintf(b, " end_error=%q", s.EndError.Error())
}
s.mu.Lock()
if s.ErrorCount > 0 {
fmt.Fprintf(b, " soft_errors=%d", s.ErrorCount)
}
s.mu.Unlock()
return b.String()
}
+182
View File
@@ -0,0 +1,182 @@
package common
import (
"fmt"
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
func TestStreamStatus_SetEndReason_FirstWins(t *testing.T) {
t.Parallel()
s := NewStreamStatus()
s.SetEndReason(StreamEndReasonDone, nil)
s.SetEndReason(StreamEndReasonTimeout, nil)
s.SetEndReason(StreamEndReasonClientGone, fmt.Errorf("context canceled"))
assert.Equal(t, StreamEndReasonDone, s.EndReason)
assert.Nil(t, s.EndError)
}
func TestStreamStatus_SetEndReason_WithError(t *testing.T) {
t.Parallel()
s := NewStreamStatus()
expectedErr := fmt.Errorf("read: connection reset")
s.SetEndReason(StreamEndReasonScannerErr, expectedErr)
assert.Equal(t, StreamEndReasonScannerErr, s.EndReason)
assert.Equal(t, expectedErr, s.EndError)
}
func TestStreamStatus_SetEndReason_NilSafe(t *testing.T) {
t.Parallel()
var s *StreamStatus
s.SetEndReason(StreamEndReasonDone, nil)
}
func TestStreamStatus_SetEndReason_Concurrent(t *testing.T) {
t.Parallel()
s := NewStreamStatus()
reasons := []StreamEndReason{
StreamEndReasonDone,
StreamEndReasonTimeout,
StreamEndReasonClientGone,
StreamEndReasonScannerErr,
StreamEndReasonHandlerStop,
StreamEndReasonEOF,
StreamEndReasonPanic,
StreamEndReasonPingFail,
}
var wg sync.WaitGroup
for _, r := range reasons {
wg.Add(1)
go func(reason StreamEndReason) {
defer wg.Done()
s.SetEndReason(reason, nil)
}(r)
}
wg.Wait()
assert.NotEqual(t, StreamEndReasonNone, s.EndReason)
}
func TestStreamStatus_RecordError_Basic(t *testing.T) {
t.Parallel()
s := NewStreamStatus()
s.RecordError("bad json")
s.RecordError("another bad json")
s.RecordError("client gone")
assert.True(t, s.HasErrors())
assert.Equal(t, 3, s.TotalErrorCount())
assert.Len(t, s.Errors, 3)
}
func TestStreamStatus_RecordError_CapAtMax(t *testing.T) {
t.Parallel()
s := NewStreamStatus()
for i := 0; i < 30; i++ {
s.RecordError(fmt.Sprintf("error_%d", i))
}
assert.Equal(t, maxStreamErrorEntries, len(s.Errors))
assert.Equal(t, 30, s.TotalErrorCount())
}
func TestStreamStatus_RecordError_NilSafe(t *testing.T) {
t.Parallel()
var s *StreamStatus
s.RecordError("should not panic")
}
func TestStreamStatus_RecordError_Concurrent(t *testing.T) {
t.Parallel()
s := NewStreamStatus()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
s.RecordError(fmt.Sprintf("error_%d", idx))
}(i)
}
wg.Wait()
assert.Equal(t, 100, s.TotalErrorCount())
assert.LessOrEqual(t, len(s.Errors), maxStreamErrorEntries)
}
func TestStreamStatus_HasErrors_Empty(t *testing.T) {
t.Parallel()
s := NewStreamStatus()
assert.False(t, s.HasErrors())
assert.Equal(t, 0, s.TotalErrorCount())
}
func TestStreamStatus_HasErrors_NilSafe(t *testing.T) {
t.Parallel()
var s *StreamStatus
assert.False(t, s.HasErrors())
assert.Equal(t, 0, s.TotalErrorCount())
}
func TestStreamStatus_IsNormalEnd(t *testing.T) {
t.Parallel()
tests := []struct {
reason StreamEndReason
normal bool
}{
{StreamEndReasonDone, true},
{StreamEndReasonEOF, true},
{StreamEndReasonHandlerStop, true},
{StreamEndReasonTimeout, false},
{StreamEndReasonClientGone, false},
{StreamEndReasonScannerErr, false},
{StreamEndReasonPanic, false},
{StreamEndReasonPingFail, false},
{StreamEndReasonNone, false},
}
for _, tt := range tests {
s := NewStreamStatus()
s.SetEndReason(tt.reason, nil)
assert.Equal(t, tt.normal, s.IsNormalEnd(), "reason=%s", tt.reason)
}
}
func TestStreamStatus_IsNormalEnd_NilSafe(t *testing.T) {
t.Parallel()
var s *StreamStatus
assert.True(t, s.IsNormalEnd())
}
func TestStreamStatus_Summary(t *testing.T) {
t.Parallel()
s := NewStreamStatus()
s.SetEndReason(StreamEndReasonDone, nil)
summary := s.Summary()
assert.Contains(t, summary, "reason=done")
assert.NotContains(t, summary, "soft_errors")
s2 := NewStreamStatus()
s2.SetEndReason(StreamEndReasonTimeout, nil)
s2.RecordError("bad json")
s2.RecordError("write failed")
summary2 := s2.Summary()
assert.Contains(t, summary2, "reason=timeout")
assert.Contains(t, summary2, "soft_errors=2")
}
func TestStreamStatus_Summary_NilSafe(t *testing.T) {
t.Parallel()
var s *StreamStatus
assert.Equal(t, "StreamStatus<nil>", s.Summary())
}
+2 -293
View File
@@ -6,25 +6,20 @@ import (
"io"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
"github.com/samber/lo"
"github.com/shopspring/decimal"
"github.com/gin-gonic/gin"
)
@@ -93,7 +88,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
if containAudioTokens && containsAudioRatios {
service.PostAudioConsumeQuota(c, info, usage, "")
} else {
postConsumeQuota(c, info, usage)
service.PostTextConsumeQuota(c, info, usage, nil)
}
return nil
}
@@ -216,293 +211,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
if containAudioTokens && containsAudioRatios {
service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "")
} else {
postConsumeQuota(c, info, usage.(*dto.Usage))
service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), nil)
}
return nil
}
func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent ...string) {
originUsage := usage
if usage == nil {
usage = &dto.Usage{
PromptTokens: relayInfo.GetEstimatePromptTokens(),
CompletionTokens: 0,
TotalTokens: relayInfo.GetEstimatePromptTokens(),
}
extraContent = append(extraContent, "上游无计费信息")
}
if originUsage != nil {
service.ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
}
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
promptTokens := usage.PromptTokens
cacheTokens := usage.PromptTokensDetails.CachedTokens
imageTokens := usage.PromptTokensDetails.ImageTokens
audioTokens := usage.PromptTokensDetails.AudioTokens
completionTokens := usage.CompletionTokens
cachedCreationTokens := usage.PromptTokensDetails.CachedCreationTokens
modelName := relayInfo.OriginModelName
tokenName := ctx.GetString("token_name")
completionRatio := relayInfo.PriceData.CompletionRatio
cacheRatio := relayInfo.PriceData.CacheRatio
imageRatio := relayInfo.PriceData.ImageRatio
modelRatio := relayInfo.PriceData.ModelRatio
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
modelPrice := relayInfo.PriceData.ModelPrice
cachedCreationRatio := relayInfo.PriceData.CacheCreationRatio
// Convert values to decimal for precise calculation
dPromptTokens := decimal.NewFromInt(int64(promptTokens))
dCacheTokens := decimal.NewFromInt(int64(cacheTokens))
dImageTokens := decimal.NewFromInt(int64(imageTokens))
dAudioTokens := decimal.NewFromInt(int64(audioTokens))
dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
dCachedCreationTokens := decimal.NewFromInt(int64(cachedCreationTokens))
dCompletionRatio := decimal.NewFromFloat(completionRatio)
dCacheRatio := decimal.NewFromFloat(cacheRatio)
dImageRatio := decimal.NewFromFloat(imageRatio)
dModelRatio := decimal.NewFromFloat(modelRatio)
dGroupRatio := decimal.NewFromFloat(groupRatio)
dModelPrice := decimal.NewFromFloat(modelPrice)
dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
ratio := dModelRatio.Mul(dGroupRatio)
// openai web search 工具计费
var dWebSearchQuota decimal.Decimal
var webSearchPrice float64
// response api 格式工具计费
if relayInfo.ResponsesUsageInfo != nil {
if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool.CallCount > 0 {
// 计算 web search 调用的配额 (配额 = 价格 * 调用次数 / 1000 * 分组倍率)
webSearchPrice = operation_setting.GetWebSearchPricePerThousand(modelName, webSearchTool.SearchContextSize)
dWebSearchQuota = decimal.NewFromFloat(webSearchPrice).
Mul(decimal.NewFromInt(int64(webSearchTool.CallCount))).
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
extraContent = append(extraContent, fmt.Sprintf("Web Search 调用 %d 次,上下文大小 %s,调用花费 %s",
webSearchTool.CallCount, webSearchTool.SearchContextSize, dWebSearchQuota.String()))
}
} else if strings.HasSuffix(modelName, "search-preview") {
// search-preview 模型不支持 response api
searchContextSize := ctx.GetString("chat_completion_web_search_context_size")
if searchContextSize == "" {
searchContextSize = "medium"
}
webSearchPrice = operation_setting.GetWebSearchPricePerThousand(modelName, searchContextSize)
dWebSearchQuota = decimal.NewFromFloat(webSearchPrice).
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
extraContent = append(extraContent, fmt.Sprintf("Web Search 调用 1 次,上下文大小 %s,调用花费 %s",
searchContextSize, dWebSearchQuota.String()))
}
// claude web search tool 计费
var dClaudeWebSearchQuota decimal.Decimal
var claudeWebSearchPrice float64
claudeWebSearchCallCount := ctx.GetInt("claude_web_search_requests")
if claudeWebSearchCallCount > 0 {
claudeWebSearchPrice = operation_setting.GetClaudeWebSearchPricePerThousand()
dClaudeWebSearchQuota = decimal.NewFromFloat(claudeWebSearchPrice).
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit).Mul(decimal.NewFromInt(int64(claudeWebSearchCallCount)))
extraContent = append(extraContent, fmt.Sprintf("Claude Web Search 调用 %d 次,调用花费 %s",
claudeWebSearchCallCount, dClaudeWebSearchQuota.String()))
}
// file search tool 计费
var dFileSearchQuota decimal.Decimal
var fileSearchPrice float64
if relayInfo.ResponsesUsageInfo != nil {
if fileSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolFileSearch]; exists && fileSearchTool.CallCount > 0 {
fileSearchPrice = operation_setting.GetFileSearchPricePerThousand()
dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice).
Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))).
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
extraContent = append(extraContent, fmt.Sprintf("File Search 调用 %d 次,调用花费 %s",
fileSearchTool.CallCount, dFileSearchQuota.String()))
}
}
var dImageGenerationCallQuota decimal.Decimal
var imageGenerationCallPrice float64
if ctx.GetBool("image_generation_call") {
imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size"))
dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit)
extraContent = append(extraContent, fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String()))
}
var quotaCalculateDecimal decimal.Decimal
var audioInputQuota decimal.Decimal
var audioInputPrice float64
isClaudeUsageSemantic := relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude
if !relayInfo.PriceData.UsePrice {
baseTokens := dPromptTokens
// 减去 cached tokens
// Anthropic API 的 input_tokens 已经不包含缓存 tokens,不需要减去
// OpenAI/OpenRouter 等 API 的 prompt_tokens 包含缓存 tokens,需要减去
var cachedTokensWithRatio decimal.Decimal
if !dCacheTokens.IsZero() {
if !isClaudeUsageSemantic {
baseTokens = baseTokens.Sub(dCacheTokens)
}
cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
}
var dCachedCreationTokensWithRatio decimal.Decimal
if !dCachedCreationTokens.IsZero() {
if !isClaudeUsageSemantic {
baseTokens = baseTokens.Sub(dCachedCreationTokens)
}
dCachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCachedCreationRatio)
}
// 减去 image tokens
var imageTokensWithRatio decimal.Decimal
if !dImageTokens.IsZero() {
baseTokens = baseTokens.Sub(dImageTokens)
imageTokensWithRatio = dImageTokens.Mul(dImageRatio)
}
// 减去 Gemini audio tokens
if !dAudioTokens.IsZero() {
audioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(modelName)
if audioInputPrice > 0 {
// 重新计算 base tokens
baseTokens = baseTokens.Sub(dAudioTokens)
audioInputQuota = decimal.NewFromFloat(audioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
extraContent = append(extraContent, fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String()))
}
}
promptQuota := baseTokens.Add(cachedTokensWithRatio).
Add(imageTokensWithRatio).
Add(dCachedCreationTokensWithRatio)
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio)
if !ratio.IsZero() && quotaCalculateDecimal.LessThanOrEqual(decimal.Zero) {
quotaCalculateDecimal = decimal.NewFromInt(1)
}
} else {
quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
}
// 添加 responses tools call 调用的配额
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
// 添加 audio input 独立计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
// 添加 image generation call 计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota)
if len(relayInfo.PriceData.OtherRatios) > 0 {
for key, otherRatio := range relayInfo.PriceData.OtherRatios {
dOtherRatio := decimal.NewFromFloat(otherRatio)
quotaCalculateDecimal = quotaCalculateDecimal.Mul(dOtherRatio)
extraContent = append(extraContent, fmt.Sprintf("其他倍率 %s: %f", key, otherRatio))
}
}
quota := int(quotaCalculateDecimal.Round(0).IntPart())
totalTokens := promptTokens + completionTokens
//var logContent string
// record all the consume log even if quota is 0
if totalTokens == 0 {
// in this case, must be some error happened
// we cannot just return, because we may have to return the pre-consumed quota
quota = 0
extraContent = append(extraContent, "上游没有返回计费信息,无法扣费(可能是上游超时)")
logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+
"tokenId %d, model %s pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota))
} else {
if !ratio.IsZero() && quota == 0 {
quota = 1
}
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
}
if err := service.SettleBilling(ctx, relayInfo, quota); err != nil {
logger.LogError(ctx, "error settling billing: "+err.Error())
}
logModel := modelName
if strings.HasPrefix(logModel, "gpt-4-gizmo") {
logModel = "gpt-4-gizmo-*"
extraContent = append(extraContent, fmt.Sprintf("模型 %s", modelName))
}
if strings.HasPrefix(logModel, "gpt-4o-gizmo") {
logModel = "gpt-4o-gizmo-*"
extraContent = append(extraContent, fmt.Sprintf("模型 %s", modelName))
}
logContent := strings.Join(extraContent, ", ")
other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
if adminRejectReason != "" {
other["reject_reason"] = adminRejectReason
}
// For chat-based calls to the Claude model, tagging is required. Using Claude's rendering logs, the two approaches handle input rendering differently.
if isClaudeUsageSemantic {
other["claude"] = true
other["usage_semantic"] = "anthropic"
}
if imageTokens != 0 {
other["image"] = true
other["image_ratio"] = imageRatio
other["image_output"] = imageTokens
}
if cachedCreationTokens != 0 {
other["cache_creation_tokens"] = cachedCreationTokens
other["cache_creation_ratio"] = cachedCreationRatio
}
if !dWebSearchQuota.IsZero() {
if relayInfo.ResponsesUsageInfo != nil {
if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists {
other["web_search"] = true
other["web_search_call_count"] = webSearchTool.CallCount
other["web_search_price"] = webSearchPrice
}
} else if strings.HasSuffix(modelName, "search-preview") {
other["web_search"] = true
other["web_search_call_count"] = 1
other["web_search_price"] = webSearchPrice
}
} else if !dClaudeWebSearchQuota.IsZero() {
other["web_search"] = true
other["web_search_call_count"] = claudeWebSearchCallCount
other["web_search_price"] = claudeWebSearchPrice
}
if !dFileSearchQuota.IsZero() && relayInfo.ResponsesUsageInfo != nil {
if fileSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolFileSearch]; exists {
other["file_search"] = true
other["file_search_call_count"] = fileSearchTool.CallCount
other["file_search_price"] = fileSearchPrice
}
}
if !audioInputQuota.IsZero() {
other["audio_input_seperate_price"] = true
other["audio_input_token_count"] = audioTokens
other["audio_input_price"] = audioInputPrice
}
if !dImageGenerationCallQuota.IsZero() {
other["image_generation_call"] = true
other["image_generation_call_price"] = imageGenerationCallPrice
}
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
ModelName: logModel,
TokenName: tokenName,
Quota: quota,
Content: logContent,
TokenId: relayInfo.TokenId,
UseTimeSeconds: int(useTimeSeconds),
IsStream: relayInfo.IsStream,
Group: relayInfo.UsingGroup,
Other: other,
})
}
+1 -1
View File
@@ -82,6 +82,6 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
}
postConsumeQuota(c, info, usage.(*dto.Usage))
service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), nil)
return nil
}
+2 -2
View File
@@ -194,7 +194,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
return openaiErr
}
postConsumeQuota(c, info, usage.(*dto.Usage))
service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), nil)
return nil
}
@@ -288,6 +288,6 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
return openaiErr
}
postConsumeQuota(c, info, usage.(*dto.Usage))
service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), nil)
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package helper
import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
)
// StreamResult is passed to each dataHandler invocation, providing methods
// to record soft errors, signal fatal stops, or mark normal completion.
// StreamScannerHandler checks IsStopped() after each callback invocation.
type StreamResult struct {
status *relaycommon.StreamStatus
stopped bool
}
func newStreamResult(status *relaycommon.StreamStatus) *StreamResult {
return &StreamResult{status: status}
}
// Error records a soft error. The stream continues processing.
// Can be called multiple times per chunk.
func (r *StreamResult) Error(err error) {
if err == nil {
return
}
r.status.RecordError(err.Error())
}
// Stop records a fatal error and marks the stream to stop after this chunk.
func (r *StreamResult) Stop(err error) {
if err != nil {
r.status.RecordError(err.Error())
}
r.status.SetEndReason(relaycommon.StreamEndReasonHandlerStop, err)
r.stopped = true
}
// Done signals that the handler has finished processing normally
// (e.g., Dify "message_end"). The stream stops after this chunk.
func (r *StreamResult) Done() {
r.status.SetEndReason(relaycommon.StreamEndReasonDone, nil)
r.stopped = true
}
// IsStopped returns whether Stop() or Done() was called during this chunk.
func (r *StreamResult) IsStopped() bool {
return r.stopped
}
// reset clears the per-chunk stopped flag so the object can be reused.
func (r *StreamResult) reset() {
r.stopped = false
}
+26 -10
View File
@@ -34,12 +34,15 @@ func getScannerBufferSize() int {
return DefaultMaxScannerBufferSize
}
func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, dataHandler func(data string) bool) {
func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, dataHandler func(data string, sr *StreamResult)) {
if resp == nil || dataHandler == nil {
return
}
// 无条件新建 StreamStatus
info.StreamStatus = relaycommon.NewStreamStatus()
// 确保响应体总是被关闭
defer func() {
if resp.Body != nil {
@@ -121,6 +124,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
wg.Done()
if r := recover(); r != nil {
logger.LogError(c, fmt.Sprintf("ping goroutine panic: %v", r))
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("ping panic: %v", r))
common.SafeSendBool(stopChan, true)
}
if common.DebugEnabled {
@@ -148,6 +152,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
case err := <-done:
if err != nil {
logger.LogError(c, "ping data error: "+err.Error())
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPingFail, err)
return
}
if common.DebugEnabled {
@@ -155,6 +160,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
}
case <-time.After(10 * time.Second):
logger.LogError(c, "ping data send timeout")
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPingFail, fmt.Errorf("ping send timeout"))
return
case <-ctx.Done():
return
@@ -184,14 +190,17 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
wg.Done()
if r := recover(); r != nil {
logger.LogError(c, fmt.Sprintf("data handler goroutine panic: %v", r))
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("handler panic: %v", r))
}
common.SafeSendBool(stopChan, true)
}()
sr := newStreamResult(info.StreamStatus)
for data := range dataChan {
sr.reset()
writeMutex.Lock()
success := dataHandler(data)
dataHandler(data, sr)
writeMutex.Unlock()
if !success {
if sr.IsStopped() {
return
}
}
@@ -205,6 +214,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
wg.Done()
if r := recover(); r != nil {
logger.LogError(c, fmt.Sprintf("scanner goroutine panic: %v", r))
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("scanner panic: %v", r))
}
common.SafeSendBool(stopChan, true)
if common.DebugEnabled {
@@ -220,6 +230,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
case <-ctx.Done():
return
case <-c.Request.Context().Done():
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, c.Request.Context().Err())
return
default:
}
@@ -253,7 +264,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
return
}
} else {
// done, 处理完成标志,直接退出停止读取剩余数据防止出错
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonDone, nil)
if common.DebugEnabled {
println("received [DONE], stopping scanner")
}
@@ -264,20 +275,25 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
if err := scanner.Err(); err != nil {
if err != io.EOF {
logger.LogError(c, "scanner error: "+err.Error())
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonScannerErr, err)
}
}
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonEOF, nil)
})
// 主循环等待完成或超时
select {
case <-ticker.C:
// 超时处理逻辑
logger.LogError(c, "streaming timeout")
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonTimeout, nil)
case <-stopChan:
// 正常结束
logger.LogInfo(c, "streaming finished")
// EndReason already set by the goroutine that triggered stopChan
case <-c.Request.Context().Done():
// 客户端断开连接
logger.LogInfo(c, "client disconnected")
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, c.Request.Context().Err())
}
if info.StreamStatus.IsNormalEnd() && !info.StreamStatus.HasErrors() {
logger.LogInfo(c, fmt.Sprintf("stream ended: %s", info.StreamStatus.Summary()))
} else {
logger.LogError(c, fmt.Sprintf("stream ended: %s, received=%d", info.StreamStatus.Summary(), info.ReceivedResponseCount))
}
}
+216 -47
View File
@@ -56,8 +56,6 @@ func buildSSEBody(n int) string {
return b.String()
}
// slowReader wraps a reader and injects a delay before each Read call,
// simulating a slow upstream that trickles data.
type slowReader struct {
r io.Reader
delay time.Duration
@@ -79,7 +77,7 @@ func TestStreamScannerHandler_NilInputs(t *testing.T) {
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}}
StreamScannerHandler(c, nil, info, func(data string) bool { return true })
StreamScannerHandler(c, nil, info, func(data string, sr *StreamResult) {})
StreamScannerHandler(c, &http.Response{Body: io.NopCloser(strings.NewReader(""))}, info, nil)
}
@@ -89,9 +87,8 @@ func TestStreamScannerHandler_EmptyBody(t *testing.T) {
c, resp, info := setupStreamTest(t, strings.NewReader(""))
var called atomic.Bool
StreamScannerHandler(c, resp, info, func(data string) bool {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
called.Store(true)
return true
})
assert.False(t, called.Load(), "handler should not be called for empty body")
@@ -105,9 +102,8 @@ func TestStreamScannerHandler_1000Chunks(t *testing.T) {
c, resp, info := setupStreamTest(t, strings.NewReader(body))
var count atomic.Int64
StreamScannerHandler(c, resp, info, func(data string) bool {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
count.Add(1)
return true
})
assert.Equal(t, int64(numChunks), count.Load())
@@ -124,9 +120,8 @@ func TestStreamScannerHandler_10000Chunks(t *testing.T) {
var count atomic.Int64
start := time.Now()
StreamScannerHandler(c, resp, info, func(data string) bool {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
count.Add(1)
return true
})
elapsed := time.Since(start)
@@ -145,11 +140,10 @@ func TestStreamScannerHandler_OrderPreserved(t *testing.T) {
var mu sync.Mutex
received := make([]string, 0, numChunks)
StreamScannerHandler(c, resp, info, func(data string) bool {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
mu.Lock()
received = append(received, data)
mu.Unlock()
return true
})
require.Equal(t, numChunks, len(received))
@@ -166,31 +160,32 @@ func TestStreamScannerHandler_DoneStopsScanner(t *testing.T) {
c, resp, info := setupStreamTest(t, strings.NewReader(body))
var count atomic.Int64
StreamScannerHandler(c, resp, info, func(data string) bool {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
count.Add(1)
return true
})
assert.Equal(t, int64(50), count.Load(), "data after [DONE] must not be processed")
}
func TestStreamScannerHandler_HandlerFailureStops(t *testing.T) {
func TestStreamScannerHandler_StopStopsStream(t *testing.T) {
t.Parallel()
const numChunks = 200
body := buildSSEBody(numChunks)
c, resp, info := setupStreamTest(t, strings.NewReader(body))
const failAt = 50
const stopAt int64 = 50
var count atomic.Int64
StreamScannerHandler(c, resp, info, func(data string) bool {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
n := count.Add(1)
return n < failAt
if n >= stopAt {
sr.Stop(fmt.Errorf("fatal at %d", n))
}
})
// The worker stops at failAt; the scanner may have read ahead,
// but the handler should not be called beyond failAt.
assert.Equal(t, int64(failAt), count.Load())
assert.Equal(t, stopAt, count.Load())
require.NotNil(t, info.StreamStatus)
assert.Equal(t, relaycommon.StreamEndReasonHandlerStop, info.StreamStatus.EndReason)
}
func TestStreamScannerHandler_SkipsNonDataLines(t *testing.T) {
@@ -210,9 +205,8 @@ func TestStreamScannerHandler_SkipsNonDataLines(t *testing.T) {
c, resp, info := setupStreamTest(t, strings.NewReader(b.String()))
var count atomic.Int64
StreamScannerHandler(c, resp, info, func(data string) bool {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
count.Add(1)
return true
})
assert.Equal(t, int64(100), count.Load())
@@ -225,25 +219,18 @@ func TestStreamScannerHandler_DataWithExtraSpaces(t *testing.T) {
c, resp, info := setupStreamTest(t, strings.NewReader(body))
var got string
StreamScannerHandler(c, resp, info, func(data string) bool {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
got = data
return true
})
assert.Equal(t, "{\"trimmed\":true}", got)
}
// ---------- Decoupling: scanner not blocked by slow handler ----------
// ---------- Decoupling ----------
func TestStreamScannerHandler_ScannerDecoupledFromSlowHandler(t *testing.T) {
t.Parallel()
// Strategy: use a slow upstream (io.Pipe, 10ms per chunk) AND a slow handler (20ms per chunk).
// If the scanner were synchronously coupled to the handler, total time would be
// ~numChunks * (10ms + 20ms) = 30ms * 50 = 1500ms.
// With decoupling, total time should be closer to
// ~numChunks * max(10ms, 20ms) = 20ms * 50 = 1000ms
// because the scanner reads ahead into the buffer while the handler processes.
const numChunks = 50
const upstreamDelay = 10 * time.Millisecond
const handlerDelay = 20 * time.Millisecond
@@ -273,10 +260,9 @@ func TestStreamScannerHandler_ScannerDecoupledFromSlowHandler(t *testing.T) {
start := time.Now()
done := make(chan struct{})
go func() {
StreamScannerHandler(c, resp, info, func(data string) bool {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
time.Sleep(handlerDelay)
count.Add(1)
return true
})
close(done)
}()
@@ -293,7 +279,6 @@ func TestStreamScannerHandler_ScannerDecoupledFromSlowHandler(t *testing.T) {
coupledTime := time.Duration(numChunks) * (upstreamDelay + handlerDelay)
t.Logf("elapsed=%v, coupled_estimate=%v", elapsed, coupledTime)
// If decoupled, elapsed should be well under the coupled estimate.
assert.Less(t, elapsed, coupledTime*85/100,
"decoupled elapsed time (%v) should be significantly less than coupled estimate (%v)", elapsed, coupledTime)
}
@@ -311,9 +296,8 @@ func TestStreamScannerHandler_SlowUpstreamFastHandler(t *testing.T) {
done := make(chan struct{})
go func() {
StreamScannerHandler(c, resp, info, func(data string) bool {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
count.Add(1)
return true
})
close(done)
}()
@@ -344,8 +328,6 @@ func TestStreamScannerHandler_PingSentDuringSlowUpstream(t *testing.T) {
setting.PingIntervalSeconds = oldSeconds
})
// Create a reader that delivers data slowly: one chunk every 500ms over 3.5 seconds.
// The ping interval is 1s, so we should see at least 2 pings.
pr, pw := io.Pipe()
go func() {
defer pw.Close()
@@ -372,9 +354,8 @@ func TestStreamScannerHandler_PingSentDuringSlowUpstream(t *testing.T) {
var count atomic.Int64
done := make(chan struct{})
go func() {
StreamScannerHandler(c, resp, info, func(data string) bool {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
count.Add(1)
return true
})
close(done)
}()
@@ -436,9 +417,8 @@ func TestStreamScannerHandler_PingDisabledByRelayInfo(t *testing.T) {
var count atomic.Int64
done := make(chan struct{})
go func() {
StreamScannerHandler(c, resp, info, func(data string) bool {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
count.Add(1)
return true
})
close(done)
}()
@@ -456,6 +436,199 @@ func TestStreamScannerHandler_PingDisabledByRelayInfo(t *testing.T) {
assert.Equal(t, 0, pingCount, "pings should be disabled when DisablePing=true")
}
// ---------- StreamStatus integration ----------
func TestStreamScannerHandler_StreamStatus_DoneReason(t *testing.T) {
t.Parallel()
body := buildSSEBody(10)
c, resp, info := setupStreamTest(t, strings.NewReader(body))
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
require.NotNil(t, info.StreamStatus)
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
assert.Nil(t, info.StreamStatus.EndError)
assert.True(t, info.StreamStatus.IsNormalEnd())
assert.False(t, info.StreamStatus.HasErrors())
}
func TestStreamScannerHandler_StreamStatus_EOFWithoutDone(t *testing.T) {
t.Parallel()
var b strings.Builder
for i := 0; i < 5; i++ {
fmt.Fprintf(&b, "data: {\"id\":%d}\n", i)
}
c, resp, info := setupStreamTest(t, strings.NewReader(b.String()))
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
require.NotNil(t, info.StreamStatus)
assert.Equal(t, relaycommon.StreamEndReasonEOF, info.StreamStatus.EndReason)
assert.True(t, info.StreamStatus.IsNormalEnd())
}
func TestStreamScannerHandler_StreamStatus_HandlerStop(t *testing.T) {
t.Parallel()
body := buildSSEBody(100)
c, resp, info := setupStreamTest(t, strings.NewReader(body))
var count atomic.Int64
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
n := count.Add(1)
if n >= 10 {
sr.Stop(fmt.Errorf("stop at 10"))
}
})
require.NotNil(t, info.StreamStatus)
assert.Equal(t, relaycommon.StreamEndReasonHandlerStop, info.StreamStatus.EndReason)
assert.True(t, info.StreamStatus.HasErrors())
}
func TestStreamScannerHandler_StreamStatus_HandlerDone(t *testing.T) {
t.Parallel()
body := buildSSEBody(20)
c, resp, info := setupStreamTest(t, strings.NewReader(body))
var count atomic.Int64
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
n := count.Add(1)
if n >= 5 {
sr.Done()
}
})
assert.Equal(t, int64(5), count.Load())
require.NotNil(t, info.StreamStatus)
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
assert.False(t, info.StreamStatus.HasErrors())
}
func TestStreamScannerHandler_StreamStatus_Timeout(t *testing.T) {
// Not parallel: modifies global constant.StreamingTimeout
oldTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 2
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
pr, pw := io.Pipe()
go func() {
fmt.Fprint(pw, "data: {\"id\":1}\n")
time.Sleep(10 * time.Second)
pw.Close()
}()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
resp := &http.Response{Body: pr}
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}}
done := make(chan struct{})
go func() {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
close(done)
}()
select {
case <-done:
case <-time.After(15 * time.Second):
t.Fatal("timed out waiting for stream timeout")
}
require.NotNil(t, info.StreamStatus)
assert.Equal(t, relaycommon.StreamEndReasonTimeout, info.StreamStatus.EndReason)
assert.False(t, info.StreamStatus.IsNormalEnd())
}
func TestStreamScannerHandler_StreamStatus_SoftErrors(t *testing.T) {
t.Parallel()
body := buildSSEBody(10)
c, resp, info := setupStreamTest(t, strings.NewReader(body))
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
sr.Error(fmt.Errorf("soft error for chunk"))
})
require.NotNil(t, info.StreamStatus)
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
assert.True(t, info.StreamStatus.HasErrors())
assert.Equal(t, 10, info.StreamStatus.TotalErrorCount())
}
func TestStreamScannerHandler_StreamStatus_MultipleErrorsPerChunk(t *testing.T) {
t.Parallel()
body := buildSSEBody(5)
c, resp, info := setupStreamTest(t, strings.NewReader(body))
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
sr.Error(fmt.Errorf("error A"))
sr.Error(fmt.Errorf("error B"))
})
require.NotNil(t, info.StreamStatus)
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
assert.Equal(t, 10, info.StreamStatus.TotalErrorCount())
}
func TestStreamScannerHandler_StreamStatus_ErrorThenStop(t *testing.T) {
t.Parallel()
// Use a large body without [DONE] to avoid race between scanner's [DONE]
// and handler's Stop on the sync.Once EndReason.
var b strings.Builder
for i := 0; i < 100; i++ {
fmt.Fprintf(&b, "data: {\"id\":%d}\n", i)
}
c, resp, info := setupStreamTest(t, strings.NewReader(b.String()))
var count atomic.Int64
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
count.Add(1)
sr.Error(fmt.Errorf("soft error"))
sr.Stop(fmt.Errorf("fatal"))
})
assert.Equal(t, int64(1), count.Load())
require.NotNil(t, info.StreamStatus)
assert.Equal(t, relaycommon.StreamEndReasonHandlerStop, info.StreamStatus.EndReason)
assert.Equal(t, 2, info.StreamStatus.TotalErrorCount())
}
func TestStreamScannerHandler_StreamStatus_InitializedIfNil(t *testing.T) {
t.Parallel()
body := buildSSEBody(1)
c, resp, info := setupStreamTest(t, strings.NewReader(body))
assert.Nil(t, info.StreamStatus)
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
assert.NotNil(t, info.StreamStatus)
}
func TestStreamScannerHandler_StreamStatus_PreInitialized(t *testing.T) {
t.Parallel()
body := buildSSEBody(5)
c, resp, info := setupStreamTest(t, strings.NewReader(body))
info.StreamStatus = relaycommon.NewStreamStatus()
info.StreamStatus.RecordError("pre-existing error")
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
assert.Equal(t, 1, info.StreamStatus.TotalErrorCount())
}
func TestStreamScannerHandler_PingInterleavesWithSlowUpstream(t *testing.T) {
t.Parallel()
@@ -469,9 +642,6 @@ func TestStreamScannerHandler_PingInterleavesWithSlowUpstream(t *testing.T) {
setting.PingIntervalSeconds = oldSeconds
})
// Slow upstream + slow handler. Total stream takes ~5 seconds.
// The ping goroutine stays alive as long as the scanner is reading,
// so pings should fire between data writes.
pr, pw := io.Pipe()
go func() {
defer pw.Close()
@@ -498,9 +668,8 @@ func TestStreamScannerHandler_PingInterleavesWithSlowUpstream(t *testing.T) {
var count atomic.Int64
done := make(chan struct{})
go func() {
StreamScannerHandler(c, resp, info, func(data string) bool {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
count.Add(1)
return true
})
close(done)
}()
+12 -3
View File
@@ -117,11 +117,20 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
if request.N != nil {
imageN = *request.N
}
// n is handled via OtherRatio so it is applied exactly once in quota
// calculation (both price-based and ratio-based paths).
// Adaptors may have already set a more accurate count from the
// upstream response; only set the default when they haven't.
if _, hasN := info.PriceData.OtherRatios["n"]; !hasN {
info.PriceData.AddOtherRatio("n", float64(imageN))
}
if usage.(*dto.Usage).TotalTokens == 0 {
usage.(*dto.Usage).TotalTokens = int(imageN)
usage.(*dto.Usage).TotalTokens = 1
}
if usage.(*dto.Usage).PromptTokens == 0 {
usage.(*dto.Usage).PromptTokens = int(imageN)
usage.(*dto.Usage).PromptTokens = 1
}
quality := "standard"
@@ -141,6 +150,6 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
logContent = append(logContent, fmt.Sprintf("生成数量 %d", imageN))
}
postConsumeQuota(c, info, usage.(*dto.Usage), logContent...)
service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), logContent)
return nil
}
+7
View File
@@ -49,6 +49,13 @@ func RelayMidjourneyImage(c *gin.Context) {
if httpClient == nil {
httpClient = service.GetHttpClient()
}
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(midjourneyTask.ImageUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
c.JSON(http.StatusForbidden, gin.H{
"error": fmt.Sprintf("request blocked: %v", err),
})
return
}
resp, err := httpClient.Get(midjourneyTask.ImageUrl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
+1 -1
View File
@@ -96,6 +96,6 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
}
postConsumeQuota(c, info, usage.(*dto.Usage))
service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), nil)
return nil
}
+2 -2
View File
@@ -145,7 +145,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
info.PriceData = originPriceData
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
}
postConsumeQuota(c, info, usageDto)
service.PostTextConsumeQuota(c, info, usageDto, nil)
info.OriginModelName = originModelName
info.PriceData = originPriceData
@@ -155,7 +155,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if strings.HasPrefix(info.OriginModelName, "gpt-4o-audio") {
service.PostAudioConsumeQuota(c, info, usageDto, "")
} else {
postConsumeQuota(c, info, usageDto)
service.PostTextConsumeQuota(c, info, usageDto, nil)
}
return nil
}
+5 -3
View File
@@ -36,10 +36,10 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
// OAuth routes - specific routes must come before :provider wildcard
apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode)
apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind)
apiRouter.POST("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind)
// Non-standard OAuth (WeChat, Telegram) - keep original routes
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind)
apiRouter.POST("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind)
apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin)
apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind)
// Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route
@@ -194,6 +194,8 @@ func SetApiRouter(router *gin.Engine) {
performanceRoute.DELETE("/disk_cache", controller.ClearDiskCache)
performanceRoute.POST("/reset_stats", controller.ResetPerformanceStats)
performanceRoute.POST("/gc", controller.ForceGC)
performanceRoute.GET("/logs", controller.GetLogFiles)
performanceRoute.DELETE("/logs", controller.CleanupLogFiles)
}
ratioSyncRoute := apiRouter.Group("/ratio_sync")
ratioSyncRoute.Use(middleware.RootAuth())
@@ -224,7 +226,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.POST("/batch", controller.DeleteChannelBatch)
channelRoute.POST("/fix", controller.FixChannelsAbilities)
channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
channelRoute.POST("/fetch_models", controller.FetchModels)
channelRoute.POST("/fetch_models", middleware.RootAuth(), controller.FetchModels)
channelRoute.POST("/codex/oauth/start", controller.StartCodexOAuth)
channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth)
channelRoute.POST("/:id/codex/oauth/start", controller.StartCodexOAuthForChannel)
+8 -5
View File
@@ -610,14 +610,17 @@ func ShouldSkipRetryAfterChannelAffinityFailure(c *gin.Context) bool {
return false
}
v, ok := c.Get(ginKeyChannelAffinitySkipRetry)
if ok {
b, ok := v.(bool)
if ok {
return b
}
}
meta, ok := getChannelAffinityMeta(c)
if !ok {
return false
}
b, ok := v.(bool)
if !ok {
return false
}
return b
return meta.SkipRetry
}
func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int) {
+60
View File
@@ -116,6 +116,66 @@ func TestApplyChannelAffinityOverrideTemplate_MergeOperations(t *testing.T) {
require.Equal(t, "trim_prefix", secondOp["mode"])
}
func TestShouldSkipRetryAfterChannelAffinityFailure(t *testing.T) {
tests := []struct {
name string
ctx func() *gin.Context
want bool
}{
{
name: "nil context",
ctx: func() *gin.Context {
return nil
},
want: false,
},
{
name: "explicit skip retry flag in context",
ctx: func() *gin.Context {
ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
RuleName: "rule-explicit-flag",
SkipRetry: false,
UsingGroup: "default",
ModelName: "gpt-5",
})
ctx.Set(ginKeyChannelAffinitySkipRetry, true)
return ctx
},
want: true,
},
{
name: "fallback to matched rule meta",
ctx: func() *gin.Context {
return buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
RuleName: "rule-skip-retry",
SkipRetry: true,
UsingGroup: "default",
ModelName: "gpt-5",
})
},
want: true,
},
{
name: "no flag and no skip retry meta",
ctx: func() *gin.Context {
return buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
RuleName: "rule-no-skip-retry",
SkipRetry: false,
UsingGroup: "default",
ModelName: "gpt-5",
})
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, ShouldSkipRetryAfterChannelAffinityFailure(tt.ctx()))
})
}
}
func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
gin.SetMode(gin.TestMode)
+26 -25
View File
@@ -223,6 +223,25 @@ func generateStopBlock(index int) *dto.ClaudeResponse {
}
}
func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
if oaiUsage == nil {
return nil
}
usage := &dto.ClaudeUsage{
InputTokens: oaiUsage.PromptTokens,
OutputTokens: oaiUsage.CompletionTokens,
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
}
if oaiUsage.ClaudeCacheCreation5mTokens > 0 || oaiUsage.ClaudeCacheCreation1hTokens > 0 {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: oaiUsage.ClaudeCacheCreation5mTokens,
Ephemeral1hInputTokens: oaiUsage.ClaudeCacheCreation1hTokens,
}
}
return usage
}
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
if info.ClaudeConvertInfo.Done {
return nil
@@ -391,13 +410,8 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
InputTokens: oaiUsage.PromptTokens,
OutputTokens: oaiUsage.CompletionTokens,
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
},
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
},
@@ -419,13 +433,8 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
oaiUsage := info.ClaudeConvertInfo.Usage
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
InputTokens: oaiUsage.PromptTokens,
OutputTokens: oaiUsage.CompletionTokens,
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
},
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
},
@@ -555,13 +564,8 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
InputTokens: oaiUsage.PromptTokens,
OutputTokens: oaiUsage.CompletionTokens,
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
},
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
},
@@ -612,10 +616,7 @@ func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relayco
}
claudeResponse.Content = contents
claudeResponse.StopReason = stopReason
claudeResponse.Usage = &dto.ClaudeUsage{
InputTokens: openAIResponse.PromptTokens,
OutputTokens: openAIResponse.CompletionTokens,
}
claudeResponse.Usage = buildClaudeUsageFromOpenAIUsage(&openAIResponse.Usage)
return claudeResponse
}
+40
View File
@@ -73,8 +73,10 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
other["admin_info"] = adminInfo
appendRequestPath(ctx, relayInfo, other)
appendRequestConversionChain(relayInfo, other)
appendFinalRequestFormat(relayInfo, other)
appendBillingInfo(relayInfo, other)
appendParamOverrideInfo(relayInfo, other)
appendStreamStatus(relayInfo, other)
return other
}
@@ -85,6 +87,33 @@ func appendParamOverrideInfo(relayInfo *relaycommon.RelayInfo, other map[string]
other["po"] = relayInfo.ParamOverrideAudit
}
func appendStreamStatus(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
if relayInfo == nil || other == nil || !relayInfo.IsStream || relayInfo.StreamStatus == nil {
return
}
ss := relayInfo.StreamStatus
status := "ok"
if !ss.IsNormalEnd() || ss.HasErrors() {
status = "error"
}
streamInfo := map[string]interface{}{
"status": status,
"end_reason": string(ss.EndReason),
}
if ss.EndError != nil {
streamInfo["end_error"] = ss.EndError.Error()
}
if ss.ErrorCount > 0 {
streamInfo["error_count"] = ss.ErrorCount
messages := make([]string, 0, len(ss.Errors))
for _, e := range ss.Errors {
messages = append(messages, e.Message)
}
streamInfo["errors"] = messages
}
other["stream_status"] = streamInfo
}
func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
if relayInfo == nil || other == nil {
return
@@ -167,6 +196,17 @@ func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[st
other["request_conversion"] = chain
}
func appendFinalRequestFormat(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
if relayInfo == nil || other == nil {
return
}
if relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude {
// claude indicates the final upstream request format is Claude Messages.
// Frontend log rendering uses this to keep the original Claude input display.
other["claude"] = true
}
}
func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
info["ws"] = true
-102
View File
@@ -235,108 +235,6 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
})
}
func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) {
if usage != nil {
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
}
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
promptTokens := usage.PromptTokens
completionTokens := usage.CompletionTokens
modelName := relayInfo.OriginModelName
tokenName := ctx.GetString("token_name")
completionRatio := relayInfo.PriceData.CompletionRatio
modelRatio := relayInfo.PriceData.ModelRatio
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
modelPrice := relayInfo.PriceData.ModelPrice
cacheRatio := relayInfo.PriceData.CacheRatio
cacheTokens := usage.PromptTokensDetails.CachedTokens
cacheCreationRatio := relayInfo.PriceData.CacheCreationRatio
cacheCreationRatio5m := relayInfo.PriceData.CacheCreation5mRatio
cacheCreationRatio1h := relayInfo.PriceData.CacheCreation1hRatio
cacheCreationTokens := usage.PromptTokensDetails.CachedCreationTokens
cacheCreationTokens5m := usage.ClaudeCacheCreation5mTokens
cacheCreationTokens1h := usage.ClaudeCacheCreation1hTokens
if relayInfo.ChannelType == constant.ChannelTypeOpenRouter {
promptTokens -= cacheTokens
isUsingCustomSettings := relayInfo.PriceData.UsePrice || hasCustomModelRatio(modelName, relayInfo.PriceData.ModelRatio)
if cacheCreationTokens == 0 && relayInfo.PriceData.CacheCreationRatio != 1 && usage.Cost != 0 && !isUsingCustomSettings {
maybeCacheCreationTokens := CalcOpenRouterCacheCreateTokens(*usage, relayInfo.PriceData)
if maybeCacheCreationTokens >= 0 && promptTokens >= maybeCacheCreationTokens {
cacheCreationTokens = maybeCacheCreationTokens
}
}
promptTokens -= cacheCreationTokens
}
calculateQuota := 0.0
if !relayInfo.PriceData.UsePrice {
calculateQuota = float64(promptTokens)
calculateQuota += float64(cacheTokens) * cacheRatio
calculateQuota += float64(cacheCreationTokens5m) * cacheCreationRatio5m
calculateQuota += float64(cacheCreationTokens1h) * cacheCreationRatio1h
remainingCacheCreationTokens := cacheCreationTokens - cacheCreationTokens5m - cacheCreationTokens1h
if remainingCacheCreationTokens > 0 {
calculateQuota += float64(remainingCacheCreationTokens) * cacheCreationRatio
}
calculateQuota += float64(completionTokens) * completionRatio
calculateQuota = calculateQuota * groupRatio * modelRatio
} else {
calculateQuota = modelPrice * common.QuotaPerUnit * groupRatio
}
if modelRatio != 0 && calculateQuota <= 0 {
calculateQuota = 1
}
quota := int(calculateQuota)
totalTokens := promptTokens + completionTokens
var logContent string
// record all the consume log even if quota is 0
if totalTokens == 0 {
// in this case, must be some error happened
// we cannot just return, because we may have to return the pre-consumed quota
quota = 0
logContent += fmt.Sprintf("(可能是上游出错)")
logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+
"tokenId %d, model %s pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota))
} else {
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
}
if err := SettleBilling(ctx, relayInfo, quota); err != nil {
logger.LogError(ctx, "error settling billing: "+err.Error())
}
other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio,
cacheTokens, cacheRatio,
cacheCreationTokens, cacheCreationRatio,
cacheCreationTokens5m, cacheCreationRatio5m,
cacheCreationTokens1h, cacheCreationRatio1h,
modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
ModelName: modelName,
TokenName: tokenName,
Quota: quota,
Content: logContent,
TokenId: relayInfo.TokenId,
UseTimeSeconds: int(useTimeSeconds),
IsStream: relayInfo.IsStream,
Group: relayInfo.UsingGroup,
Other: other,
})
}
func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData) int {
if priceData.CacheCreationRatio == 1 {
return 0
+430
View File
@@ -0,0 +1,430 @@
package service
import (
"fmt"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
)
type textQuotaSummary struct {
PromptTokens int
CompletionTokens int
TotalTokens int
CacheTokens int
CacheCreationTokens int
CacheCreationTokens5m int
CacheCreationTokens1h int
ImageTokens int
AudioTokens int
ModelName string
TokenName string
UseTimeSeconds int64
CompletionRatio float64
CacheRatio float64
ImageRatio float64
ModelRatio float64
GroupRatio float64
ModelPrice float64
CacheCreationRatio float64
CacheCreationRatio5m float64
CacheCreationRatio1h float64
Quota int
IsClaudeUsageSemantic bool
UsageSemantic string
WebSearchPrice float64
WebSearchCallCount int
ClaudeWebSearchPrice float64
ClaudeWebSearchCallCount int
FileSearchPrice float64
FileSearchCallCount int
AudioInputPrice float64
ImageGenerationCallPrice float64
}
func cacheWriteTokensTotal(summary textQuotaSummary) int {
if summary.CacheCreationTokens5m > 0 || summary.CacheCreationTokens1h > 0 {
splitCacheWriteTokens := summary.CacheCreationTokens5m + summary.CacheCreationTokens1h
if summary.CacheCreationTokens > splitCacheWriteTokens {
return summary.CacheCreationTokens
}
return splitCacheWriteTokens
}
return summary.CacheCreationTokens
}
func isLegacyClaudeDerivedOpenAIUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage) bool {
if relayInfo == nil || usage == nil {
return false
}
if relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude {
return false
}
if usage.UsageSource != "" || usage.UsageSemantic != "" {
return false
}
return usage.ClaudeCacheCreation5mTokens > 0 || usage.ClaudeCacheCreation1hTokens > 0
}
func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) textQuotaSummary {
summary := textQuotaSummary{
ModelName: relayInfo.OriginModelName,
TokenName: ctx.GetString("token_name"),
UseTimeSeconds: time.Now().Unix() - relayInfo.StartTime.Unix(),
CompletionRatio: relayInfo.PriceData.CompletionRatio,
CacheRatio: relayInfo.PriceData.CacheRatio,
ImageRatio: relayInfo.PriceData.ImageRatio,
ModelRatio: relayInfo.PriceData.ModelRatio,
GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio,
ModelPrice: relayInfo.PriceData.ModelPrice,
CacheCreationRatio: relayInfo.PriceData.CacheCreationRatio,
CacheCreationRatio5m: relayInfo.PriceData.CacheCreation5mRatio,
CacheCreationRatio1h: relayInfo.PriceData.CacheCreation1hRatio,
UsageSemantic: usageSemanticFromUsage(relayInfo, usage),
}
summary.IsClaudeUsageSemantic = summary.UsageSemantic == "anthropic"
if usage == nil {
usage = &dto.Usage{
PromptTokens: relayInfo.GetEstimatePromptTokens(),
CompletionTokens: 0,
TotalTokens: relayInfo.GetEstimatePromptTokens(),
}
}
summary.PromptTokens = usage.PromptTokens
summary.CompletionTokens = usage.CompletionTokens
summary.TotalTokens = usage.PromptTokens + usage.CompletionTokens
summary.CacheTokens = usage.PromptTokensDetails.CachedTokens
summary.CacheCreationTokens = usage.PromptTokensDetails.CachedCreationTokens
summary.CacheCreationTokens5m = usage.ClaudeCacheCreation5mTokens
summary.CacheCreationTokens1h = usage.ClaudeCacheCreation1hTokens
summary.ImageTokens = usage.PromptTokensDetails.ImageTokens
summary.AudioTokens = usage.PromptTokensDetails.AudioTokens
legacyClaudeDerived := isLegacyClaudeDerivedOpenAIUsage(relayInfo, usage)
isOpenRouterClaudeBilling := relayInfo.ChannelMeta != nil &&
relayInfo.ChannelType == constant.ChannelTypeOpenRouter &&
summary.IsClaudeUsageSemantic
if isOpenRouterClaudeBilling {
summary.PromptTokens -= summary.CacheTokens
isUsingCustomSettings := relayInfo.PriceData.UsePrice || hasCustomModelRatio(summary.ModelName, relayInfo.PriceData.ModelRatio)
if summary.CacheCreationTokens == 0 && relayInfo.PriceData.CacheCreationRatio != 1 && usage.Cost != 0 && !isUsingCustomSettings {
maybeCacheCreationTokens := CalcOpenRouterCacheCreateTokens(*usage, relayInfo.PriceData)
if maybeCacheCreationTokens >= 0 && summary.PromptTokens >= maybeCacheCreationTokens {
summary.CacheCreationTokens = maybeCacheCreationTokens
}
}
summary.PromptTokens -= summary.CacheCreationTokens
}
dPromptTokens := decimal.NewFromInt(int64(summary.PromptTokens))
dCacheTokens := decimal.NewFromInt(int64(summary.CacheTokens))
dImageTokens := decimal.NewFromInt(int64(summary.ImageTokens))
dAudioTokens := decimal.NewFromInt(int64(summary.AudioTokens))
dCompletionTokens := decimal.NewFromInt(int64(summary.CompletionTokens))
dCachedCreationTokens := decimal.NewFromInt(int64(summary.CacheCreationTokens))
dCompletionRatio := decimal.NewFromFloat(summary.CompletionRatio)
dCacheRatio := decimal.NewFromFloat(summary.CacheRatio)
dImageRatio := decimal.NewFromFloat(summary.ImageRatio)
dModelRatio := decimal.NewFromFloat(summary.ModelRatio)
dGroupRatio := decimal.NewFromFloat(summary.GroupRatio)
dModelPrice := decimal.NewFromFloat(summary.ModelPrice)
dCacheCreationRatio := decimal.NewFromFloat(summary.CacheCreationRatio)
dCacheCreationRatio5m := decimal.NewFromFloat(summary.CacheCreationRatio5m)
dCacheCreationRatio1h := decimal.NewFromFloat(summary.CacheCreationRatio1h)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
ratio := dModelRatio.Mul(dGroupRatio)
var dWebSearchQuota decimal.Decimal
if relayInfo.ResponsesUsageInfo != nil {
if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool.CallCount > 0 {
summary.WebSearchCallCount = webSearchTool.CallCount
summary.WebSearchPrice = operation_setting.GetWebSearchPricePerThousand(summary.ModelName, webSearchTool.SearchContextSize)
dWebSearchQuota = decimal.NewFromFloat(summary.WebSearchPrice).
Mul(decimal.NewFromInt(int64(webSearchTool.CallCount))).
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
}
} else if strings.HasSuffix(summary.ModelName, "search-preview") {
searchContextSize := ctx.GetString("chat_completion_web_search_context_size")
if searchContextSize == "" {
searchContextSize = "medium"
}
summary.WebSearchCallCount = 1
summary.WebSearchPrice = operation_setting.GetWebSearchPricePerThousand(summary.ModelName, searchContextSize)
dWebSearchQuota = decimal.NewFromFloat(summary.WebSearchPrice).
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
}
var dClaudeWebSearchQuota decimal.Decimal
summary.ClaudeWebSearchCallCount = ctx.GetInt("claude_web_search_requests")
if summary.ClaudeWebSearchCallCount > 0 {
summary.ClaudeWebSearchPrice = operation_setting.GetClaudeWebSearchPricePerThousand()
dClaudeWebSearchQuota = decimal.NewFromFloat(summary.ClaudeWebSearchPrice).
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit).
Mul(decimal.NewFromInt(int64(summary.ClaudeWebSearchCallCount)))
}
var dFileSearchQuota decimal.Decimal
if relayInfo.ResponsesUsageInfo != nil {
if fileSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolFileSearch]; exists && fileSearchTool.CallCount > 0 {
summary.FileSearchCallCount = fileSearchTool.CallCount
summary.FileSearchPrice = operation_setting.GetFileSearchPricePerThousand()
dFileSearchQuota = decimal.NewFromFloat(summary.FileSearchPrice).
Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))).
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
}
}
var dImageGenerationCallQuota decimal.Decimal
if ctx.GetBool("image_generation_call") {
summary.ImageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size"))
dImageGenerationCallQuota = decimal.NewFromFloat(summary.ImageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit)
}
var audioInputQuota decimal.Decimal
if !relayInfo.PriceData.UsePrice {
baseTokens := dPromptTokens
var cachedTokensWithRatio decimal.Decimal
if !dCacheTokens.IsZero() {
if !summary.IsClaudeUsageSemantic && !legacyClaudeDerived {
baseTokens = baseTokens.Sub(dCacheTokens)
}
cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
}
var cachedCreationTokensWithRatio decimal.Decimal
hasSplitCacheCreationTokens := summary.CacheCreationTokens5m > 0 || summary.CacheCreationTokens1h > 0
if !dCachedCreationTokens.IsZero() || hasSplitCacheCreationTokens {
if !summary.IsClaudeUsageSemantic && !legacyClaudeDerived {
baseTokens = baseTokens.Sub(dCachedCreationTokens)
cachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCacheCreationRatio)
} else {
remaining := summary.CacheCreationTokens - summary.CacheCreationTokens5m - summary.CacheCreationTokens1h
if remaining < 0 {
remaining = 0
}
cachedCreationTokensWithRatio = decimal.NewFromInt(int64(remaining)).Mul(dCacheCreationRatio)
cachedCreationTokensWithRatio = cachedCreationTokensWithRatio.Add(decimal.NewFromInt(int64(summary.CacheCreationTokens5m)).Mul(dCacheCreationRatio5m))
cachedCreationTokensWithRatio = cachedCreationTokensWithRatio.Add(decimal.NewFromInt(int64(summary.CacheCreationTokens1h)).Mul(dCacheCreationRatio1h))
}
}
var imageTokensWithRatio decimal.Decimal
if !dImageTokens.IsZero() {
baseTokens = baseTokens.Sub(dImageTokens)
imageTokensWithRatio = dImageTokens.Mul(dImageRatio)
}
if !dAudioTokens.IsZero() {
summary.AudioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(summary.ModelName)
if summary.AudioInputPrice > 0 {
baseTokens = baseTokens.Sub(dAudioTokens)
audioInputQuota = decimal.NewFromFloat(summary.AudioInputPrice).
Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
}
}
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio).Add(cachedCreationTokensWithRatio)
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
quotaCalculateDecimal := promptQuota.Add(completionQuota).Mul(ratio)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dClaudeWebSearchQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota)
if len(relayInfo.PriceData.OtherRatios) > 0 {
for _, otherRatio := range relayInfo.PriceData.OtherRatios {
quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio))
}
}
if !ratio.IsZero() && quotaCalculateDecimal.LessThanOrEqual(decimal.Zero) {
quotaCalculateDecimal = decimal.NewFromInt(1)
}
summary.Quota = int(quotaCalculateDecimal.Round(0).IntPart())
} else {
quotaCalculateDecimal := dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dClaudeWebSearchQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota)
if len(relayInfo.PriceData.OtherRatios) > 0 {
for _, otherRatio := range relayInfo.PriceData.OtherRatios {
quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio))
}
}
summary.Quota = int(quotaCalculateDecimal.Round(0).IntPart())
}
if summary.TotalTokens == 0 {
summary.Quota = 0
} else if !ratio.IsZero() && summary.Quota == 0 {
summary.Quota = 1
}
return summary
}
func usageSemanticFromUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage) string {
if usage != nil && usage.UsageSemantic != "" {
return usage.UsageSemantic
}
if relayInfo != nil && relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude {
return "anthropic"
}
return "openai"
}
func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent []string) {
originUsage := usage
if usage == nil {
extraContent = append(extraContent, "上游无计费信息")
}
if originUsage != nil {
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
}
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
if summary.WebSearchCallCount > 0 {
extraContent = append(extraContent, fmt.Sprintf("Web Search 调用 %d 次,调用花费 %s", summary.WebSearchCallCount, decimal.NewFromFloat(summary.WebSearchPrice).Mul(decimal.NewFromInt(int64(summary.WebSearchCallCount))).Div(decimal.NewFromInt(1000)).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String()))
}
if summary.ClaudeWebSearchCallCount > 0 {
extraContent = append(extraContent, fmt.Sprintf("Claude Web Search 调用 %d 次,调用花费 %s", summary.ClaudeWebSearchCallCount, decimal.NewFromFloat(summary.ClaudeWebSearchPrice).Div(decimal.NewFromInt(1000)).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).Mul(decimal.NewFromInt(int64(summary.ClaudeWebSearchCallCount))).String()))
}
if summary.FileSearchCallCount > 0 {
extraContent = append(extraContent, fmt.Sprintf("File Search 调用 %d 次,调用花费 %s", summary.FileSearchCallCount, decimal.NewFromFloat(summary.FileSearchPrice).Mul(decimal.NewFromInt(int64(summary.FileSearchCallCount))).Div(decimal.NewFromInt(1000)).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String()))
}
if summary.AudioInputPrice > 0 && summary.AudioTokens > 0 {
extraContent = append(extraContent, fmt.Sprintf("Audio Input 花费 %s", decimal.NewFromFloat(summary.AudioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(decimal.NewFromInt(int64(summary.AudioTokens))).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String()))
}
if summary.ImageGenerationCallPrice > 0 {
extraContent = append(extraContent, fmt.Sprintf("Image Generation Call 花费 %s", decimal.NewFromFloat(summary.ImageGenerationCallPrice).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).String()))
}
if summary.TotalTokens == 0 {
extraContent = append(extraContent, "上游没有返回计费信息,无法扣费(可能是上游超时)")
logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, tokenId %d, model %s pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, summary.ModelName, relayInfo.FinalPreConsumedQuota))
} else {
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, summary.Quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, summary.Quota)
}
if err := SettleBilling(ctx, relayInfo, summary.Quota); err != nil {
logger.LogError(ctx, "error settling billing: "+err.Error())
}
logModel := summary.ModelName
if strings.HasPrefix(logModel, "gpt-4-gizmo") {
logModel = "gpt-4-gizmo-*"
extraContent = append(extraContent, fmt.Sprintf("模型 %s", summary.ModelName))
}
if strings.HasPrefix(logModel, "gpt-4o-gizmo") {
logModel = "gpt-4o-gizmo-*"
extraContent = append(extraContent, fmt.Sprintf("模型 %s", summary.ModelName))
}
logContent := strings.Join(extraContent, ", ")
var other map[string]interface{}
if summary.IsClaudeUsageSemantic {
other = GenerateClaudeOtherInfo(ctx, relayInfo,
summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio,
summary.CacheTokens, summary.CacheRatio,
summary.CacheCreationTokens, summary.CacheCreationRatio,
summary.CacheCreationTokens5m, summary.CacheCreationRatio5m,
summary.CacheCreationTokens1h, summary.CacheCreationRatio1h,
summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
other["usage_semantic"] = "anthropic"
} else {
other = GenerateTextOtherInfo(ctx, relayInfo, summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio, summary.CacheTokens, summary.CacheRatio, summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
}
if adminRejectReason != "" {
other["reject_reason"] = adminRejectReason
}
if summary.ImageTokens != 0 {
other["image"] = true
other["image_ratio"] = summary.ImageRatio
other["image_output"] = summary.ImageTokens
}
if summary.WebSearchCallCount > 0 {
other["web_search"] = true
other["web_search_call_count"] = summary.WebSearchCallCount
other["web_search_price"] = summary.WebSearchPrice
} else if summary.ClaudeWebSearchCallCount > 0 {
other["web_search"] = true
other["web_search_call_count"] = summary.ClaudeWebSearchCallCount
other["web_search_price"] = summary.ClaudeWebSearchPrice
}
if summary.FileSearchCallCount > 0 {
other["file_search"] = true
other["file_search_call_count"] = summary.FileSearchCallCount
other["file_search_price"] = summary.FileSearchPrice
}
if summary.AudioInputPrice > 0 && summary.AudioTokens > 0 {
other["audio_input_seperate_price"] = true
other["audio_input_token_count"] = summary.AudioTokens
other["audio_input_price"] = summary.AudioInputPrice
}
if summary.ImageGenerationCallPrice > 0 {
other["image_generation_call"] = true
other["image_generation_call_price"] = summary.ImageGenerationCallPrice
}
if summary.CacheCreationTokens > 0 {
other["cache_creation_tokens"] = summary.CacheCreationTokens
other["cache_creation_ratio"] = summary.CacheCreationRatio
}
if summary.CacheCreationTokens5m > 0 {
other["cache_creation_tokens_5m"] = summary.CacheCreationTokens5m
other["cache_creation_ratio_5m"] = summary.CacheCreationRatio5m
}
if summary.CacheCreationTokens1h > 0 {
other["cache_creation_tokens_1h"] = summary.CacheCreationTokens1h
other["cache_creation_ratio_1h"] = summary.CacheCreationRatio1h
}
cacheWriteTokens := cacheWriteTokensTotal(summary)
if cacheWriteTokens > 0 {
// cache_write_tokens: normalized cache creation total for UI display.
// If split 5m/1h values are present, this is their sum; otherwise it falls back
// to cache_creation_tokens.
other["cache_write_tokens"] = cacheWriteTokens
}
if relayInfo.GetFinalRequestRelayFormat() != types.RelayFormatClaude && usage != nil && usage.UsageSource != "" && usage.InputTokens > 0 {
// input_tokens_total: explicit normalized total input used by the usage log UI.
// Only write this field when upstream/current conversion has already provided a
// reliable total input value and tagged the usage source. Do not infer it from
// prompt/cache fields here, otherwise old upstream payloads may be double-counted.
other["input_tokens_total"] = usage.InputTokens
}
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: summary.PromptTokens,
CompletionTokens: summary.CompletionTokens,
ModelName: logModel,
TokenName: summary.TokenName,
Quota: summary.Quota,
Content: logContent,
TokenId: relayInfo.TokenId,
UseTimeSeconds: int(summary.UseTimeSeconds),
IsStream: relayInfo.IsStream,
Group: relayInfo.UsingGroup,
Other: other,
})
}
+318
View File
@@ -0,0 +1,318 @@
package service
import (
"net/http/httptest"
"testing"
"time"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestCalculateTextQuotaSummaryUnifiedForClaudeSemantic(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
usage := &dto.Usage{
PromptTokens: 1000,
CompletionTokens: 200,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 100,
CachedCreationTokens: 50,
},
ClaudeCacheCreation5mTokens: 10,
ClaudeCacheCreation1hTokens: 20,
}
priceData := types.PriceData{
ModelRatio: 1,
CompletionRatio: 2,
CacheRatio: 0.1,
CacheCreationRatio: 1.25,
CacheCreation5mRatio: 1.25,
CacheCreation1hRatio: 2,
GroupRatioInfo: types.GroupRatioInfo{
GroupRatio: 1,
},
}
chatRelayInfo := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatOpenAI,
FinalRequestRelayFormat: types.RelayFormatClaude,
OriginModelName: "claude-3-7-sonnet",
PriceData: priceData,
StartTime: time.Now(),
}
messageRelayInfo := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatClaude,
FinalRequestRelayFormat: types.RelayFormatClaude,
OriginModelName: "claude-3-7-sonnet",
PriceData: priceData,
StartTime: time.Now(),
}
chatSummary := calculateTextQuotaSummary(ctx, chatRelayInfo, usage)
messageSummary := calculateTextQuotaSummary(ctx, messageRelayInfo, usage)
require.Equal(t, messageSummary.Quota, chatSummary.Quota)
require.Equal(t, messageSummary.CacheCreationTokens5m, chatSummary.CacheCreationTokens5m)
require.Equal(t, messageSummary.CacheCreationTokens1h, chatSummary.CacheCreationTokens1h)
require.True(t, chatSummary.IsClaudeUsageSemantic)
require.Equal(t, 1488, chatSummary.Quota)
}
func TestCalculateTextQuotaSummaryUsesSplitClaudeCacheCreationRatios(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
relayInfo := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatOpenAI,
FinalRequestRelayFormat: types.RelayFormatClaude,
OriginModelName: "claude-3-7-sonnet",
PriceData: types.PriceData{
ModelRatio: 1,
CompletionRatio: 1,
CacheRatio: 0,
CacheCreationRatio: 1,
CacheCreation5mRatio: 2,
CacheCreation1hRatio: 3,
GroupRatioInfo: types.GroupRatioInfo{
GroupRatio: 1,
},
},
StartTime: time.Now(),
}
usage := &dto.Usage{
PromptTokens: 100,
CompletionTokens: 0,
PromptTokensDetails: dto.InputTokenDetails{
CachedCreationTokens: 10,
},
ClaudeCacheCreation5mTokens: 2,
ClaudeCacheCreation1hTokens: 3,
}
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
// 100 + remaining(5)*1 + 2*2 + 3*3 = 118
require.Equal(t, 118, summary.Quota)
}
func TestCalculateTextQuotaSummaryUsesAnthropicUsageSemanticFromUpstreamUsage(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
relayInfo := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatOpenAI,
OriginModelName: "claude-3-7-sonnet",
PriceData: types.PriceData{
ModelRatio: 1,
CompletionRatio: 2,
CacheRatio: 0.1,
CacheCreationRatio: 1.25,
CacheCreation5mRatio: 1.25,
CacheCreation1hRatio: 2,
GroupRatioInfo: types.GroupRatioInfo{
GroupRatio: 1,
},
},
StartTime: time.Now(),
}
usage := &dto.Usage{
PromptTokens: 1000,
CompletionTokens: 200,
UsageSemantic: "anthropic",
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 100,
CachedCreationTokens: 50,
},
ClaudeCacheCreation5mTokens: 10,
ClaudeCacheCreation1hTokens: 20,
}
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
require.True(t, summary.IsClaudeUsageSemantic)
require.Equal(t, "anthropic", summary.UsageSemantic)
require.Equal(t, 1488, summary.Quota)
}
func TestCacheWriteTokensTotal(t *testing.T) {
t.Run("split cache creation", func(t *testing.T) {
summary := textQuotaSummary{
CacheCreationTokens: 50,
CacheCreationTokens5m: 10,
CacheCreationTokens1h: 20,
}
require.Equal(t, 50, cacheWriteTokensTotal(summary))
})
t.Run("legacy cache creation", func(t *testing.T) {
summary := textQuotaSummary{CacheCreationTokens: 50}
require.Equal(t, 50, cacheWriteTokensTotal(summary))
})
t.Run("split cache creation without aggregate remainder", func(t *testing.T) {
summary := textQuotaSummary{
CacheCreationTokens5m: 10,
CacheCreationTokens1h: 20,
}
require.Equal(t, 30, cacheWriteTokensTotal(summary))
})
}
func TestCalculateTextQuotaSummaryHandlesLegacyClaudeDerivedOpenAIUsage(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
relayInfo := &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatOpenAI,
OriginModelName: "claude-3-7-sonnet",
PriceData: types.PriceData{
ModelRatio: 1,
CompletionRatio: 5,
CacheRatio: 0.1,
CacheCreationRatio: 1.25,
CacheCreation5mRatio: 1.25,
CacheCreation1hRatio: 2,
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
},
StartTime: time.Now(),
}
usage := &dto.Usage{
PromptTokens: 62,
CompletionTokens: 95,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 3544,
},
ClaudeCacheCreation5mTokens: 586,
}
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
// 62 + 3544*0.1 + 586*1.25 + 95*5 = 1624.9 => 1624
require.Equal(t, 1624, summary.Quota)
}
func TestCalculateTextQuotaSummarySeparatesOpenRouterCacheReadFromPromptBilling(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
relayInfo := &relaycommon.RelayInfo{
OriginModelName: "openai/gpt-4.1",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenRouter,
},
PriceData: types.PriceData{
ModelRatio: 1,
CompletionRatio: 1,
CacheRatio: 0.1,
CacheCreationRatio: 1.25,
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
},
StartTime: time.Now(),
}
usage := &dto.Usage{
PromptTokens: 2604,
CompletionTokens: 383,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 2432,
},
}
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
// OpenRouter OpenAI-format display keeps prompt_tokens as total input,
// but billing still separates normal input from cache read tokens.
// quota = (2604 - 2432) + 2432*0.1 + 383 = 798.2 => 798
require.Equal(t, 2604, summary.PromptTokens)
require.Equal(t, 798, summary.Quota)
}
func TestCalculateTextQuotaSummarySeparatesOpenRouterCacheCreationFromPromptBilling(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
relayInfo := &relaycommon.RelayInfo{
OriginModelName: "openai/gpt-4.1",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenRouter,
},
PriceData: types.PriceData{
ModelRatio: 1,
CompletionRatio: 1,
CacheCreationRatio: 1.25,
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
},
StartTime: time.Now(),
}
usage := &dto.Usage{
PromptTokens: 2604,
CompletionTokens: 383,
PromptTokensDetails: dto.InputTokenDetails{
CachedCreationTokens: 100,
},
}
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
// prompt_tokens is still logged as total input, but cache creation is billed separately.
// quota = (2604 - 100) + 100*1.25 + 383 = 3012
require.Equal(t, 2604, summary.PromptTokens)
require.Equal(t, 3012, summary.Quota)
}
func TestCalculateTextQuotaSummaryKeepsPrePRClaudeOpenRouterBilling(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
relayInfo := &relaycommon.RelayInfo{
FinalRequestRelayFormat: types.RelayFormatClaude,
OriginModelName: "anthropic/claude-3.7-sonnet",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenRouter,
},
PriceData: types.PriceData{
ModelRatio: 1,
CompletionRatio: 1,
CacheRatio: 0.1,
CacheCreationRatio: 1.25,
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
},
StartTime: time.Now(),
}
usage := &dto.Usage{
PromptTokens: 2604,
CompletionTokens: 383,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 2432,
},
}
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
// Pre-PR PostClaudeConsumeQuota behavior for OpenRouter:
// prompt = 2604 - 2432 = 172
// quota = 172 + 2432*0.1 + 383 = 798.2 => 798
require.True(t, summary.IsClaudeUsageSemantic)
require.Equal(t, 172, summary.PromptTokens)
require.Equal(t, 798, summary.Quota)
}
+1
View File
@@ -17,6 +17,7 @@ var defaultQwenSettings = QwenSettings{
"z-image",
"qwen-image",
"wan2.6",
"wan2.7",
"qwen-image-edit",
"qwen-image-edit-max",
"qwen-image-edit-max-2026-01-16",
@@ -88,7 +88,7 @@ var channelAffinitySetting = ChannelAffinitySetting{
ValueRegex: "",
TTLSeconds: 0,
ParamOverrideTemplate: buildPassHeaderTemplate(codexCliPassThroughHeaders),
SkipRetryOnFailure: false,
SkipRetryOnFailure: true,
IncludeUsingGroup: true,
IncludeRuleName: true,
UserAgentInclude: nil,
@@ -103,7 +103,7 @@ var channelAffinitySetting = ChannelAffinitySetting{
ValueRegex: "",
TTLSeconds: 0,
ParamOverrideTemplate: buildPassHeaderTemplate(claudeCliPassThroughHeaders),
SkipRetryOnFailure: false,
SkipRetryOnFailure: true,
IncludeUsingGroup: true,
IncludeRuleName: true,
UserAgentInclude: nil,
+1 -1
View File
@@ -36,7 +36,7 @@ var performanceSetting = PerformanceSetting{
MonitorEnabled: true,
MonitorCPUThreshold: 90,
MonitorMemoryThreshold: 90,
MonitorDiskThreshold: 90,
MonitorDiskThreshold: 95,
}
func init() {
+3
View File
@@ -510,6 +510,9 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
// gpt-5 匹配
if strings.HasPrefix(name, "gpt-5") {
if strings.HasPrefix(name, "gpt-5.4") {
if strings.HasPrefix(name, "gpt-5.4-nano") {
return 6.25, true
}
return 6, true
}
return 8, true
+1 -1
View File
@@ -21,7 +21,7 @@ var defaultFetchSetting = FetchSetting{
DomainList: []string{},
IpList: []string{},
AllowedPorts: []string{"80", "443", "8080", "8443"},
ApplyIPFilterForDomain: false,
ApplyIPFilterForDomain: true,
}
func init() {
+1 -1
View File
@@ -56,7 +56,7 @@ const OAuth2Callback = (props) => {
return;
}
if (message === 'bind') {
if (data?.action === 'bind') {
showSuccess(t('绑定成功!'));
navigate('/console/personal');
} else {
+20 -16
View File
@@ -221,23 +221,27 @@ const FooterBar = () => {
return (
<div className='w-full'>
{footer ? (
<div className='relative'>
<div
className='custom-footer'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
<div className='absolute bottom-2 right-4 text-xs !text-semi-color-text-2 opacity-70'>
<span>{t('设计与开发由')} </span>
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-primary font-medium'
>
New API
</a>
<footer className='relative h-auto py-4 px-6 md:px-24 w-full flex items-center justify-center overflow-hidden'>
<div className='flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-4'>
<div
className='custom-footer na-cb6feafeb3990c78 text-sm !text-semi-color-text-1'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
<div className='text-sm flex-shrink-0'>
<span className='!text-semi-color-text-1'>
{t('设计与开发由')}{' '}
</span>
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-primary font-medium'
>
New API
</a>
</div>
</div>
</div>
</footer>
) : (
customFooter
)}
@@ -306,9 +306,9 @@ const PersonalSetting = () => {
const bindWeChat = async () => {
if (inputs.wechat_verification_code === '') return;
const res = await API.get(
`/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`,
);
const res = await API.post('/api/oauth/wechat/bind', {
code: inputs.wechat_verification_code,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('微信账户绑定成功!'));
@@ -378,9 +378,10 @@ const PersonalSetting = () => {
return;
}
setLoading(true);
const res = await API.get(
`/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`,
);
const res = await API.post('/api/oauth/email/bind', {
email: inputs.email,
code: inputs.email_verification_code,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('邮箱账户绑定成功!'));
@@ -108,7 +108,7 @@ const SystemSetting = () => {
'fetch_setting.domain_list': [],
'fetch_setting.ip_list': [],
'fetch_setting.allowed_ports': [],
'fetch_setting.apply_ip_filter_for_domain': false,
'fetch_setting.apply_ip_filter_for_domain': true,
});
const [originInputs, setOriginInputs] = useState({});
@@ -847,7 +847,7 @@ const SystemSetting = () => {
}
style={{ marginBottom: 8 }}
>
{t('对域名启用 IP 过滤(实验性')}
{t('对域名启用 IP 过滤(推荐开启')}
</Form.Checkbox>
<Text strong>
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
@@ -538,19 +538,24 @@ export const getChannelsColumns = ({
</Tooltip>
<Tooltip
content={
t('剩余额度') +
': ' +
renderQuotaWithAmount(record.balance) +
t(',点击更新')
record.type === 57
? t('查看 Codex 帐号信息与用量')
: t('剩余额度') +
': ' +
renderQuotaWithAmount(record.balance) +
t(',点击更新')
}
>
<Tag
color='white'
type='ghost'
color={record.type === 57 ? 'light-blue' : 'white'}
type={record.type === 57 ? 'light' : 'ghost'}
shape='circle'
className={record.type === 57 ? 'cursor-pointer' : ''}
onClick={() => updateChannelBalance(record)}
>
{renderQuotaWithAmount(record.balance)}
{record.type === 57
? t('帐号信息')
: renderQuotaWithAmount(record.balance)}
</Tag>
</Tooltip>
</Space>
@@ -22,9 +22,11 @@ import {
Modal,
Button,
Progress,
Tag,
Typography,
Spin,
Tag,
Descriptions,
Collapse,
} from '@douyinfe/semi-ui';
import { API, showError } from '../../../../helpers';
@@ -128,6 +130,87 @@ const formatUnixSeconds = (unixSeconds) => {
}
};
const getDisplayText = (value) => {
if (value == null) return '';
return String(value).trim();
};
const formatAccountTypeLabel = (value, t) => {
const tt = typeof t === 'function' ? t : (v) => v;
const normalized = normalizePlanType(value);
switch (normalized) {
case 'free':
return 'Free';
case 'plus':
return 'Plus';
case 'pro':
return 'Pro';
case 'team':
return 'Team';
case 'enterprise':
return 'Enterprise';
default:
return getDisplayText(value) || tt('未识别');
}
};
const getAccountTypeTagColor = (value) => {
const normalized = normalizePlanType(value);
switch (normalized) {
case 'enterprise':
return 'green';
case 'team':
return 'cyan';
case 'pro':
return 'blue';
case 'plus':
return 'violet';
case 'free':
return 'amber';
default:
return 'grey';
}
};
const resolveUsageStatusTag = (t, rateLimit) => {
const tt = typeof t === 'function' ? t : (v) => v;
if (!rateLimit || Object.keys(rateLimit).length === 0) {
return <Tag color='grey'>{tt('待确认')}</Tag>;
}
if (rateLimit?.allowed && !rateLimit?.limit_reached) {
return <Tag color='green'>{tt('可用')}</Tag>;
}
return <Tag color='red'>{tt('受限')}</Tag>;
};
const AccountInfoValue = ({ t, value, onCopy, monospace = false }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const text = getDisplayText(value);
const hasValue = text !== '';
return (
<div className='flex min-w-0 items-start justify-between gap-2'>
<div
className={`min-w-0 flex-1 break-all text-xs leading-5 text-semi-color-text-1 ${
monospace ? 'font-mono' : ''
}`}
>
{hasValue ? text : '-'}
</div>
<Button
size='small'
type='tertiary'
theme='borderless'
className='shrink-0 px-1 text-xs'
disabled={!hasValue}
onClick={() => onCopy?.(text)}
>
{tt('复制')}
</Button>
</div>
);
};
const RateLimitWindowCard = ({ t, title, windowData }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const hasWindowData =
@@ -181,50 +264,100 @@ const RateLimitWindowCard = ({ t, title, windowData }) => {
const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const [showRawJson, setShowRawJson] = useState(false);
const data = payload?.data ?? null;
const rateLimit = data?.rate_limit ?? {};
const { fiveHourWindow, weeklyWindow } = resolveRateLimitWindows(data);
const allowed = !!rateLimit?.allowed;
const limitReached = !!rateLimit?.limit_reached;
const upstreamStatus = payload?.upstream_status;
const statusTag =
allowed && !limitReached ? (
<Tag color='green'>{tt('可用')}</Tag>
) : (
<Tag color='red'>{tt('受限')}</Tag>
);
const accountType = data?.plan_type ?? rateLimit?.plan_type;
const accountTypeLabel = formatAccountTypeLabel(accountType, tt);
const accountTypeTagColor = getAccountTypeTagColor(accountType);
const statusTag = resolveUsageStatusTag(tt, rateLimit);
const userId = data?.user_id;
const email = data?.email;
const accountId = data?.account_id;
const errorMessage =
payload?.success === false ? getDisplayText(payload?.message) || tt('获取用量失败') : '';
const rawText =
typeof data === 'string' ? data : JSON.stringify(data ?? payload, null, 2);
return (
<div className='flex flex-col gap-3'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<Text type='tertiary' size='small'>
{tt('渠道:')}
{record?.name || '-'} ({tt('编号:')}
{record?.id || '-'})
</Text>
<div className='flex items-center gap-2'>
{statusTag}
<Button
size='small'
type='tertiary'
theme='borderless'
onClick={onRefresh}
>
<div className='flex flex-col gap-4'>
{errorMessage && (
<div className='rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700'>
{errorMessage}
</div>
)}
<div className='rounded-xl border border-semi-color-border bg-semi-color-bg-0 p-3'>
<div className='flex flex-wrap items-start justify-between gap-2'>
<div className='min-w-0'>
<div className='text-xs font-medium text-semi-color-text-2'>
{tt('Codex 帐号')}
</div>
<div className='mt-2 flex flex-wrap items-center gap-2'>
<Tag
color={accountTypeTagColor}
type='light'
shape='circle'
size='large'
className='font-semibold'
>
{accountTypeLabel}
</Tag>
{statusTag}
<Tag color='grey' type='light' shape='circle'>
{tt('上游状态码:')}
{upstreamStatus ?? '-'}
</Tag>
</div>
</div>
<Button size='small' type='tertiary' theme='outline' onClick={onRefresh}>
{tt('刷新')}
</Button>
</div>
<div className='mt-2 rounded-lg bg-semi-color-fill-0 px-3 py-2'>
<Descriptions>
<Descriptions.Item itemKey='User ID'>
<AccountInfoValue
t={tt}
value={userId}
onCopy={onCopy}
monospace={true}
/>
</Descriptions.Item>
<Descriptions.Item itemKey={tt('邮箱')}>
<AccountInfoValue t={tt} value={email} onCopy={onCopy} />
</Descriptions.Item>
<Descriptions.Item itemKey='Account ID'>
<AccountInfoValue
t={tt}
value={accountId}
onCopy={onCopy}
monospace={true}
/>
</Descriptions.Item>
</Descriptions>
</div>
<div className='mt-2 text-xs text-semi-color-text-2'>
{tt('渠道:')}
{record?.name || '-'} ({tt('编号:')}
{record?.id || '-'})
</div>
</div>
<div className='flex flex-wrap items-center justify-between gap-2'>
<Text type='tertiary' size='small'>
{tt('上游状态码:')}
{upstreamStatus ?? '-'}
</Text>
<div>
<div className='mb-2'>
<div className='text-sm font-semibold text-semi-color-text-0'>
{tt('额度窗口')}
</div>
<Text type='tertiary' size='small'>
{tt('用于观察当前帐号在 Codex 上游的限额使用情况')}
</Text>
</div>
</div>
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
@@ -240,23 +373,30 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
/>
</div>
<div>
<div className='mb-1 flex items-center justify-between gap-2'>
<div className='text-sm font-medium'>{tt('原始 JSON')}</div>
<Button
size='small'
type='primary'
theme='outline'
onClick={() => onCopy?.(rawText)}
disabled={!rawText}
>
{tt('复制')}
</Button>
</div>
<pre className='max-h-[50vh] overflow-auto rounded-lg bg-semi-color-fill-0 p-3 text-xs text-semi-color-text-0'>
{rawText}
</pre>
</div>
<Collapse
activeKey={showRawJson ? ['raw-json'] : []}
onChange={(activeKey) => {
const keys = Array.isArray(activeKey) ? activeKey : [activeKey];
setShowRawJson(keys.includes('raw-json'));
}}
>
<Collapse.Panel header={tt('原始 JSON')} itemKey='raw-json'>
<div className='mb-2 flex justify-end'>
<Button
size='small'
type='primary'
theme='outline'
onClick={() => onCopy?.(rawText)}
disabled={!rawText}
>
{tt('复制')}
</Button>
</div>
<pre className='max-h-[50vh] overflow-y-auto rounded-lg bg-semi-color-fill-0 p-3 text-xs text-semi-color-text-0'>
{rawText}
</pre>
</Collapse.Panel>
</Collapse>
</div>
);
};
@@ -351,7 +491,7 @@ export const openCodexUsageModal = ({ t, record, payload, onCopy }) => {
const tt = typeof t === 'function' ? t : (v) => v;
Modal.info({
title: tt('Codex 用量'),
title: tt('Codex 帐号与用量'),
centered: true,
width: 900,
style: { maxWidth: '95vw' },
File diff suppressed because it is too large Load Diff
@@ -116,6 +116,8 @@ const renderTokenKey = (
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
t,
) => {
const revealed = !!showKeys[record.id];
const loading = !!loadingTokenKeys[record.id];
@@ -145,18 +147,35 @@ const renderTokenKey = (
await toggleTokenVisibility(record);
}}
/>
<Button
theme='borderless'
size='small'
type='tertiary'
icon={<IconCopy />}
loading={loading}
aria-label='copy token key'
onClick={async (e) => {
e.stopPropagation();
await copyTokenKey(record);
}}
/>
<Dropdown
trigger='click'
position='bottomRight'
clickToHide
menu={[
{
node: 'item',
name: t('复制密钥'),
onClick: () => copyTokenKey(record),
},
{
node: 'item',
name: t('复制连接信息'),
onClick: () => copyTokenConnectionString(record),
},
]}
>
<Button
theme='borderless'
size='small'
type='tertiary'
icon={<IconCopy />}
loading={loading}
aria-label='copy token key'
onClick={async (e) => {
e.stopPropagation();
}}
/>
</Dropdown>
</div>
}
/>
@@ -444,6 +463,7 @@ export const getTokensColumns = ({
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
manageToken,
onOpenLink,
setEditingToken,
@@ -484,6 +504,8 @@ export const getTokensColumns = ({
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
t,
),
},
{
@@ -43,6 +43,7 @@ const TokensTable = (tokensData) => {
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
manageToken,
onOpenLink,
setEditingToken,
@@ -60,6 +61,7 @@ const TokensTable = (tokensData) => {
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
manageToken,
onOpenLink,
setEditingToken,
@@ -73,6 +75,7 @@ const TokensTable = (tokensData) => {
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
manageToken,
onOpenLink,
setEditingToken,
+2 -2
View File
@@ -68,7 +68,7 @@ export const CHANNEL_AFFINITY_RULE_TEMPLATES = {
param_override_template: CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
value_regex: '',
ttl_seconds: 0,
skip_retry_on_failure: false,
skip_retry_on_failure: true,
include_using_group: true,
include_rule_name: true,
},
@@ -80,7 +80,7 @@ export const CHANNEL_AFFINITY_RULE_TEMPLATES = {
param_override_template: CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
value_regex: '',
ttl_seconds: 0,
skip_retry_on_failure: false,
skip_retry_on_failure: true,
include_using_group: true,
include_rule_name: true,
},
+38
View File
@@ -80,3 +80,41 @@ export function getServerAddress() {
return serverAddress;
}
export const CHANNEL_CONN_CLIPBOARD_TYPE = 'newapi_channel_conn';
/**
* @param {string} key - 完整的 API key sk- 前缀
* @param {string} url - 服务器地址
* @returns {string} JSON 格式的连接字符串
*/
export function encodeChannelConnectionString(key, url) {
return JSON.stringify({
_type: CHANNEL_CONN_CLIPBOARD_TYPE,
key,
url,
});
}
/**
* @param {string} text - 剪贴板文本
* @returns {{ key: string, url: string } | null}
*/
export function parseChannelConnectionString(text) {
if (!text || typeof text !== 'string') return null;
try {
const parsed = JSON.parse(text.trim());
if (
parsed &&
typeof parsed === 'object' &&
parsed._type === CHANNEL_CONN_CLIPBOARD_TYPE &&
typeof parsed.key === 'string' &&
typeof parsed.url === 'string'
) {
return { key: parsed.key, url: parsed.url };
}
} catch {
// not valid JSON
}
return null;
}
+13 -1
View File
@@ -29,7 +29,11 @@ import {
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
import { fetchTokenKey as fetchTokenKeyById } from '../../helpers/token';
import {
fetchTokenKey as fetchTokenKeyById,
getServerAddress,
encodeChannelConnectionString,
} from '../../helpers/token';
export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
const { t } = useTranslation();
@@ -198,6 +202,13 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
await copyText(`sk-${fullKey}`);
};
const copyTokenConnectionString = async (record) => {
const fullKey = await fetchTokenKey(record);
const serverUrl = getServerAddress();
const connStr = encodeChannelConnectionString(`sk-${fullKey}`, serverUrl);
await copyText(connStr);
};
// Open link function for chat integrations
const onOpenLink = async (type, url, record) => {
const fullKey = await fetchTokenKey(record);
@@ -465,6 +476,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
fetchTokenKey,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
onOpenLink,
manageToken,
searchTokens,
+26
View File
@@ -601,6 +601,32 @@ export const useLogsData = () => {
value: other.request_path,
});
}
if (isAdminUser && other?.stream_status) {
const ss = other.stream_status;
const isOk = ss.status === 'ok';
const statusLabel = isOk ? '✓ ' + t('正常') : '✗ ' + t('异常');
let streamValue = statusLabel + ' (' + (ss.end_reason || 'unknown') + ')';
if (ss.error_count > 0) {
streamValue += ` [${t('软错误')}: ${ss.error_count}]`;
}
if (ss.end_error) {
streamValue += ` - ${ss.end_error}`;
}
expandDataLocal.push({
key: t('流状态'),
value: streamValue,
});
if (Array.isArray(ss.errors) && ss.errors.length > 0) {
expandDataLocal.push({
key: t('流错误详情'),
value: (
<div style={{ maxWidth: 600, whiteSpace: 'pre-line', wordBreak: 'break-word', lineHeight: 1.6 }}>
{ss.errors.join('\n')}
</div>
),
});
}
}
if (Array.isArray(other?.po) && other.po.length > 0) {
expandDataLocal.push({
key: t('参数覆盖'),
+2
View File
@@ -52,4 +52,6 @@ i18n
},
});
window.__i18n = i18n;
export default i18n;
+45 -3
View File
@@ -503,6 +503,10 @@
"保存邮箱域名白名单设置": "Save Email Domain Whitelist Settings",
"保存额度设置": "Save Quota Settings",
"保留原值(目标已有值时不覆盖)": "Keep original value (do not overwrite if target already has a value)",
"保留天数": "Days to Retain",
"保留文件数": "Files to Retain",
"保留最近N个文件": "Retain last N files",
"保留最近N天": "Retain last N days",
"修复数据库一致性": "Fix database consistency",
"修改为": "Modify to",
"修改子渠道优先级": "Modify sub-channel priority",
@@ -931,7 +935,7 @@
"在此输入系统名称": "Enter the system name here",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Enter privacy policy content here, supports Markdown & HTML code",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "Enter the home page content here, supports Markdown",
"域名IP过滤详细说明": "⚠️ This is an experimental option. A domain may resolve to multiple IPv4/IPv6 addresses. If enabled, ensure the IP filter list covers these addresses, otherwise access may fail.",
"域名IP过滤详细说明": "Recommended: When enabled, domains are resolved via DNS and the resulting IPs are checked against private address ranges, effectively preventing DNS rebinding attacks that bypass SSRF protection. Note: A domain may resolve to multiple IPv4/IPv6 addresses. If you have configured an IP filter list, ensure it covers these addresses, otherwise access may fail.",
"域名白名单": "Domain Whitelist",
"域名黑名单": "Domain Blacklist",
"基本信息": "Basic Information",
@@ -1101,7 +1105,7 @@
"密钥预览": "Key preview",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "For official channels, the new-api has a built-in address. Unless it is a third-party proxy site or a special Azure access address, there is no need to fill it in",
"对免费模型启用预消耗": "Enable pre-consumption for free models",
"对域名启用 IP 过滤(实验性": "Enable IP filtering for domains (experimental)",
"对域名启用 IP 过滤(推荐开启": "Enable IP filtering for domains (recommended)",
"对外运营模式": "Default mode",
"对象清理规则": "Object Pruning Rules",
"导入": "Import",
@@ -1116,8 +1120,10 @@
"将为选中的 ": "Will set for selected ",
"将仅保留第一个密钥文件,其余文件将被移除,是否继续?": "Only the first key file will be retained, and the remaining files will be removed. Continue?",
"将删除": "Deleting",
"将删除 {{value}} 天前的日志文件。": "Log files older than {{value}} days will be deleted.",
"将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "This will delete all used, disabled, and expired redemption codes, this operation cannot be undone.",
"将删除所有仍在内存中的渠道亲和性缓存条目。": "This will delete all channel affinity cache entries still in memory.",
"将只保留最近 {{value}} 个日志文件,其余将被删除。": "Only the last {{value}} log files will be retained; the rest will be deleted.",
"将大请求体临时存储到磁盘": "Store large request bodies temporarily on disk",
"将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?": "This will clear all saved configurations and restore default settings, this operation cannot be undone. Continue?",
"将清除选定时间之前的所有日志": "This will clear all logs before the selected time",
@@ -1202,6 +1208,7 @@
"已添加 {{count}} 个模板_one": "Added {{count}} template",
"已添加 {{count}} 个模板_other": "Added {{count}} templates",
"已添加到白名单": "Added to whitelist",
"已清理 {{count}} 个日志文件,释放 {{size}}": "Cleaned up {{count}} log files, freed {{size}}",
"已清空": "Cleared",
"已清空测试结果": "Cleared test results",
"已生成授权凭据": "Authorization credentials generated",
@@ -1500,6 +1507,8 @@
"收益统计": "Income statistics",
"收起": "Collapse",
"收起侧边栏": "Collapse sidebar",
"向左展开": "Expand left",
"向右展开": "Expand right",
"收起内容": "Collapse content",
"放大": "Upscalers",
"放大编辑": "Expand editor",
@@ -1570,8 +1579,12 @@
"日志已下载": "Logs downloaded",
"日志已加载": "Logs loaded",
"日志已复制到剪贴板": "Logs copied to clipboard",
"日志时间范围": "Log Date Range",
"日志总大小": "Total Log Size",
"日志文件数": "Log File Count",
"日志流": "Log Stream",
"日志清理失败:": "Log cleanup failed:",
"日志目录": "Log Directory",
"日志类型": "Log type",
"日志设置": "Log settings",
"日志详情": "Log details",
@@ -1690,6 +1703,8 @@
"服务可用性": "Service Status",
"服务商": "Service Provider",
"服务器地址": "Server Address",
"服务器日志功能未启用(未配置日志目录)": "Server logging is not enabled (log directory not configured)",
"服务器日志管理": "Server Log Management",
"服务显示名称": "Service Display Name",
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "No matching models. Press Enter to add \"{{name}}\" as a custom model name.",
"未发现新增模型": "No new models were added",
@@ -1986,6 +2001,8 @@
"添加额度": "Add quota",
"清理不活跃缓存": "Clean up inactive cache",
"清理失败": "Cleanup failed",
"清理方式": "Cleanup Mode",
"清理日志文件": "Clean Up Log Files",
"清空": "Clear",
"清空全部缓存": "Clear All Cache",
"清空该规则缓存": "Clear This Rule's Cache",
@@ -2186,6 +2203,7 @@
"确认操作": "Confirm Operation",
"确认新密码": "Confirm new password",
"确认清理不活跃的磁盘缓存?": "Confirm cleanup of inactive disk cache?",
"确认清理日志文件?": "Confirm log file cleanup?",
"确认清空全部渠道亲和性缓存": "Confirm clearing all channel affinity cache",
"确认清空该规则缓存": "Confirm clearing this rule's cache",
"确认清除历史日志": "Confirm clear historical logs",
@@ -2285,6 +2303,7 @@
"管理员设置了外部链接,点击下方按钮访问": "Administrator has set up external links, click the button below to access",
"管理员账号": "Admin account",
"管理员账号已经初始化过,请继续设置其他参数": "The admin account has already been initialized, please continue to set other parameters",
"管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。": "Manage server log files. Log files accumulate over time; regular cleanup is recommended to free disk space.",
"管理模型、标签、端点等预填组": "Manage model, tag, endpoint, etc. pre-filled groups",
"管理用户已绑定的第三方账户,支持筛选与解绑": "Manage users' linked third-party accounts, with filtering and unbinding support",
"管理绑定": "Manage Bindings",
@@ -2659,6 +2678,11 @@
"请求结束后多退少补": "Adjust after request completion",
"请求超时,请刷新页面后重新发起 GitHub 登录": "Request timed out, please refresh and restart GitHub login",
"请求路径": "Request path",
"流状态": "Stream Status",
"流错误详情": "Stream Error Details",
"软错误": "soft errors",
"正常": "Normal",
"异常": "Abnormal",
"请求转换": "Request conversion",
"请求预扣费额度": "Pre-deduction quota for requests",
"请点击我": "Please click me",
@@ -2755,6 +2779,7 @@
"请输入新的部署名称": "Please enter new deployment name",
"请输入显示名称": "Please enter display name",
"请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "Please enter a valid JSON format request body. You can refer to the default request body format in the preview panel.",
"请输入有效的数值": "Please enter a valid number",
"请输入有效的数字": "Please enter a valid number",
"请输入有效的镜像地址": "Please enter a valid image address",
"请输入标签名称": "Please enter the tag name",
@@ -3202,6 +3227,12 @@
"高级设置": "Advanced Settings",
"高级选项": "Advanced Options",
"高级配置": "Advanced Configuration",
"核心配置": "Core Configuration",
"创建渠道所需的基本信息": "Basic information needed to create a channel",
"请求配置": "Request Configuration",
"渠道行为": "Channel Behavior",
"额外设置": "Extra Settings",
"上游模型管理": "Upstream Model Management",
"黑名单": "Blacklist",
"默认": "Default",
"默认 API 版本": "Default API Version",
@@ -3319,6 +3350,17 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "Input Price: {{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "Output Price {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "Output Price: {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "Output Price: {{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "Output Price: {{symbol}}{{total}} / 1M tokens",
"例如:gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$": "Example: gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$",
"支持精确匹配;使用 regex: 开头可按正则匹配。": "Supports exact matching. Use a regex: prefix for regex matching.",
"复制密钥": "Copy Key",
"复制连接信息": "Copy Connection String",
"检测到剪贴板中的连接信息": "Connection info detected in clipboard",
"自动填入": "Auto-fill",
"忽略": "Ignore",
"从剪贴板粘贴配置": "Paste Config",
"剪贴板中未检测到连接信息": "No connection info found in clipboard",
"连接信息已填入": "Connection info applied",
"无法读取剪贴板": "Cannot read clipboard"
}
}
+38 -3
View File
@@ -498,6 +498,10 @@
"保存邮箱域名白名单设置": "Enregistrer les paramètres de liste blanche des domaines de messagerie",
"保存额度设置": "Enregistrer les paramètres de quota",
"保留原值(目标已有值时不覆盖)": "Conserver la valeur originale (ne pas écraser si la cible a déjà une valeur)",
"保留天数": "Jours à conserver",
"保留文件数": "Fichiers à conserver",
"保留最近N个文件": "Conserver les N derniers fichiers",
"保留最近N天": "Conserver les N derniers jours",
"修复数据库一致性": "Réparer la cohérence de la base de données",
"修改为": "Modifier en",
"修改子渠道优先级": "Modifier la priorité du sous-canal",
@@ -924,7 +928,7 @@
"在此输入系统名称": "Saisissez le nom du système ici",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Saisissez le contenu de la politique de confidentialité ici, prend en charge le code Markdown & HTML",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "Saisissez le contenu de la page d'accueil ici, prend en charge Markdown & HTML. Après configuration, les informations d'état de la page d'accueil ne seront plus affichées. Si un lien est saisi, il sera utilisé comme attribut src de l'iframe, ce qui vous permet de définir n'importe quelle page web comme page d'accueil",
"域名IP过滤详细说明": "⚠️ Il s'agit d'une option expérimentale. Un domaine peut se résoudre en plusieurs adresses IPv4/IPv6. Si cette option est activée, assurez-vous que la liste de filtres IP couvre ces adresses, sinon l'accès peut échouer.",
"域名IP过滤详细说明": "Recommandé : lorsqu'il est activé, les domaines sont résolus par DNS et les IP résultantes sont vérifiées par rapport aux plages d'adresses privées, ce qui empêche efficacement les attaques de DNS rebinding qui contournent la protection SSRF. Remarque : un domaine peut se résoudre en plusieurs adresses IPv4/IPv6. Si vous avez configuré une liste de filtres IP, assurez-vous qu'elle couvre ces adresses, sinon l'accès peut échouer.",
"域名白名单": "Liste blanche de domaines",
"域名黑名单": "Liste noire de domaines",
"基本信息": "Informations de base",
@@ -1093,7 +1097,7 @@
"密钥预览": "Aperçu de la clé",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "Pour les canaux officiels, le new-api a une adresse intégrée. Sauf s'il s'agit d'un site proxy tiers ou d'une adresse d'accès Azure spéciale, il n'est pas nécessaire de la remplir",
"对免费模型启用预消耗": "Activer la préconsommation pour les modèles gratuits",
"对域名启用 IP 过滤(实验性": "Activer le filtrage IP pour les domaines (expérimental)",
"对域名启用 IP 过滤(推荐开启": "Activer le filtrage IP pour les domaines (recommandé)",
"对外运营模式": "Mode par défaut",
"对象清理规则": "Règles de nettoyage d'objets",
"导入": "Importer",
@@ -1108,8 +1112,10 @@
"将为选中的 ": "Définira pour la sélection ",
"将仅保留第一个密钥文件,其余文件将被移除,是否继续?": "Seul le premier fichier de clé sera conservé, et les fichiers restants seront supprimés. Continuer ?",
"将删除": "Supprimera",
"将删除 {{value}} 天前的日志文件。": "Les fichiers journaux de plus de {{value}} jours seront supprimés.",
"将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "Cela supprimera tous les codes d'échange utilisés, désactivés et expirés, cette opération ne peut pas être annulée.",
"将删除所有仍在内存中的渠道亲和性缓存条目。": "Ceci supprimera toutes les entrées de cache d'affinité de canal encore en mémoire.",
"将只保留最近 {{value}} 个日志文件,其余将被删除。": "Seuls les {{value}} derniers fichiers journaux seront conservés ; le reste sera supprimé.",
"将大请求体临时存储到磁盘": "Stocker temporairement les grands corps de requête sur le disque",
"将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?": "Effacera toutes les configurations enregistrées et rétablira les paramètres par défaut. Cette opération ne peut pas être annulée. Continuer ?",
"将清除选定时间之前的所有日志": "Effacera tous les journaux avant l'heure sélectionnée",
@@ -1201,6 +1207,7 @@
"已添加到白名单": "Ajouté à la liste blanche",
"已清空": "Vidé",
"已清空测试结果": "Résultats de test effacés",
"已清理 {{count}} 个日志文件,释放 {{size}}": "{{count}} fichiers journaux nettoyés, {{size}} libérés",
"已生成授权凭据": "Identifiants d'autorisation générés",
"已用": "Used",
"已用/剩余": "Utilisé/Restant",
@@ -1488,6 +1495,8 @@
"收益统计": "Statistiques sur les revenus",
"收起": "Réduire",
"收起侧边栏": "Réduire la barre latérale",
"向左展开": "Développer à gauche",
"向右展开": "Développer à droite",
"收起内容": "Réduire le contenu",
"放大": "Upscalers",
"放大编辑": "Développer l'éditeur",
@@ -1563,6 +1572,10 @@
"日志类型": "Type de journal",
"日志设置": "Config. journaux",
"日志详情": "Détails du journal",
"日志目录": "Répertoire des journaux",
"日志文件数": "Nombre de fichiers journaux",
"日志时间范围": "Plage de dates des journaux",
"日志总大小": "Taille totale des journaux",
"旧格式(JSON 对象)": "Ancien format (Objet JSON)",
"旧格式(直接覆盖):": "Ancien format (remplacement direct) :",
"旧格式必须是 JSON 对象": "L'ancien format doit être un objet JSON",
@@ -1677,6 +1690,8 @@
"服务可用性": "État du service",
"服务商": "Service Provider",
"服务器地址": "Adresse du serveur",
"服务器日志功能未启用(未配置日志目录)": "La journalisation du serveur n'est pas activée (répertoire de journaux non configuré)",
"服务器日志管理": "Gestion des journaux du serveur",
"服务显示名称": "Nom d'affichage du service",
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "Aucun modèle correspondant. Appuyez sur Entrée pour ajouter «{{name}}» comme nom de modèle personnalisé.",
"未发现新增模型": "Aucun nouveau modèle n'a été ajouté",
@@ -1966,6 +1981,8 @@
"添加额度": "Ajouter un quota",
"清理不活跃缓存": "Nettoyer le cache inactif",
"清理失败": "Échec du nettoyage",
"清理方式": "Mode de nettoyage",
"清理日志文件": "Nettoyer les fichiers journaux",
"清空": "Clear",
"清空全部缓存": "Vider tout le cache",
"清空该规则缓存": "Vider le cache de cette règle",
@@ -2164,6 +2181,7 @@
"确认操作": "Confirm Operation",
"确认新密码": "Confirmer le nouveau mot de passe",
"确认清理不活跃的磁盘缓存?": "Confirmer le nettoyage du cache disque inactif ?",
"确认清理日志文件?": "Confirmer le nettoyage des fichiers journaux ?",
"确认清空全部渠道亲和性缓存": "Confirmer la suppression de tout le cache d'affinité de canal",
"确认清空该规则缓存": "Confirmer la suppression du cache de cette règle",
"确认清除历史日志": "Confirmer l'effacement des journaux historiques",
@@ -2249,6 +2267,7 @@
"简洁模式仅返回 message;状态码和错误类型将使用系统默认值。": "Le mode simple renvoie uniquement le message ; le code d'état et le type d'erreur utiliseront les valeurs par défaut du système.",
"管理": "Gérer",
"管理 Ollama 模型的拉取和删除": "Manage Ollama model pulling and deletion",
"管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。": "Gérer les fichiers journaux du serveur. Les fichiers journaux s'accumulent au fil du temps ; un nettoyage régulier est recommandé pour libérer de l'espace disque.",
"管理你的 LinuxDO OAuth App": "Gérer votre application OAuth LinuxDO",
"管理员": "Admin",
"管理员区域": "Zone administrateur",
@@ -2714,6 +2733,7 @@
"请输入新的部署名称": "Please enter new deployment name",
"请输入显示名称": "Veuillez saisir un nom d'affichage",
"请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "Veuillez entrer un corps de requête au format JSON valide. Vous pouvez vous référer au format de corps de requête par défaut dans le panneau d'aperçu.",
"请输入有效的数值": "Veuillez saisir une valeur valide",
"请输入有效的数字": "Veuillez saisir un nombre valide",
"请输入有效的镜像地址": "Please enter a valid image address",
"请输入标签名称": "Veuillez saisir le nom de l'étiquette",
@@ -3146,6 +3166,12 @@
"高级设置": "Paramètres avancés",
"高级选项": "Options avancées",
"高级配置": "Advanced Configuration",
"核心配置": "Configuration principale",
"创建渠道所需的基本信息": "Informations de base pour créer un canal",
"请求配置": "Configuration des requêtes",
"渠道行为": "Comportement du canal",
"额外设置": "Paramètres supplémentaires",
"上游模型管理": "Gestion des modèles amont",
"黑名单": "Liste noire",
"默认": "Par défaut",
"默认 API 版本": "Version de l'API par défaut",
@@ -3282,6 +3308,15 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "Prix d'entrée : {{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "Prix de sortie {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "Prix de sortie : {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "Prix de sortie : {{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "Prix de sortie : {{symbol}}{{total}} / 1M tokens",
"复制密钥": "Copier la clé",
"复制连接信息": "Copier les infos de connexion",
"检测到剪贴板中的连接信息": "Informations de connexion détectées dans le presse-papiers",
"自动填入": "Remplir auto",
"忽略": "Ignorer",
"从剪贴板粘贴配置": "Coller la config",
"剪贴板中未检测到连接信息": "Aucune info de connexion trouvée dans le presse-papiers",
"连接信息已填入": "Informations de connexion appliquées",
"无法读取剪贴板": "Impossible de lire le presse-papiers"
}
}
+38 -3
View File
@@ -494,6 +494,10 @@
"保存邮箱域名白名单设置": "メールドメインのホワイトリスト設定を保存",
"保存额度设置": "クォータ設定を保存",
"保留原值(目标已有值时不覆盖)": "元の値を保持(ターゲットに既に値がある場合は上書きしない)",
"保留天数": "保持日数",
"保留文件数": "保持ファイル数",
"保留最近N个文件": "最新のN個のファイルを保持",
"保留最近N天": "最新のN日間を保持",
"修复数据库一致性": "データベースの整合性を修復",
"修改为": "変更先:",
"修改子渠道优先级": "サブチャネルの優先度を変更",
@@ -915,7 +919,7 @@
"在此输入系统名称": "システム名称を入力してください",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "プライバシーポリシーのコンテンツを入力してください。MarkdownとHTMLコードに対応しています",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "ホームのコンテンツを入力してください。MarkdownとHTMLに対応しています。設定後は、ホームのステータス情報が表示されなくなります。リンクを入力した場合は、そのリンクがiframeのsrc属性として使用され、任意のWebページをホームとして設定できます",
"域名IP过滤详细说明": "ドメインIPフィルタリングの詳細説明",
"域名IP过滤详细说明": "推奨:有効にすると、ドメインをDNS解決し、解決されたIPがプライベートアドレスかどうかを確認します。DNSリバインディング攻撃によるSSRF防護の回避を効果的に防止できます。注意:ドメインは複数のIPv4/IPv6アドレスに解決される場合があります。IPフィルタリストを設定している場合は、これらのアドレスをカバーしていることを確認してください。",
"域名白名单": "ドメインホワイトリスト",
"域名黑名单": "ドメインブラックリスト",
"基本信息": "基本情報",
@@ -1084,7 +1088,7 @@
"密钥预览": "APIキーのプレビュー",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "公式チャネルの場合、new-apiにはベースURLが組み込まれているため、サードパーティのプロキシサイトやAzureの専用のエンドポイントでない限り、入力する必要はありません。",
"对免费模型启用预消耗": "Enable pre-consumption for free models",
"对域名启用 IP 过滤(实验性": "ドメインのIPフィルタリングを有効にする(実験的",
"对域名启用 IP 过滤(推荐开启": "ドメインのIPフィルタリングを有効にする(推奨",
"对外运营模式": "公開運用モード",
"对象清理规则": "オブジェクトプルーニングルール",
"导入": "インポート",
@@ -1101,6 +1105,8 @@
"将删除": "削除の確認",
"将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "使用済み、無効、および有効期限切れの引き換えコードを削除します。この操作は元に戻すことはできません。",
"将删除所有仍在内存中的渠道亲和性缓存条目。": "メモリ内に残っているすべてのチャネルアフィニティキャッシュエントリが削除されます。",
"将删除 {{value}} 天前的日志文件。": "{{value}} 日より前のログファイルが削除されます。",
"将只保留最近 {{value}} 个日志文件,其余将被删除。": "最新の {{value}} 個のログファイルのみ保持し、残りは削除されます。",
"将大请求体临时存储到磁盘": "大きなリクエストボディをディスクに一時保存",
"将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?": "保存されているすべての設定がクリアされ、デフォルト設定に復元されます。この操作は元に戻すことはできません。続行しますか?",
"将清除选定时间之前的所有日志": "選択した日時以前のすべてのログをクリアします",
@@ -1185,6 +1191,7 @@
"已添加到白名单": "ホワイトリストに追加されました",
"已清空": "クリア済み",
"已清空测试结果": "テスト結果がクリアされました",
"已清理 {{count}} 个日志文件,释放 {{size}}": "{{count}} 個のログファイルをクリーンアップし、{{size}} を解放しました",
"已生成授权凭据": "認可資格情報が生成されました",
"已用": "Used",
"已用/剩余": "使用済み/残り",
@@ -1471,6 +1478,8 @@
"收益统计": "収益統計",
"收起": "折りたたみ",
"收起侧边栏": "サイドバー折りたたみ",
"向左展开": "左に展開",
"向右展开": "右に展開",
"收起内容": "コンテンツ折りたたみ",
"放大": "アップスケール",
"放大编辑": "エディタで開く",
@@ -1546,6 +1555,10 @@
"日志类型": "ログタイプ",
"日志设置": "ログ設定",
"日志详情": "ログ詳細",
"日志目录": "ログディレクトリ",
"日志文件数": "ログファイル数",
"日志时间范围": "ログ期間",
"日志总大小": "ログ合計サイズ",
"旧格式(JSON 对象)": "レガシー形式(JSONオブジェクト)",
"旧格式(直接覆盖):": "旧形式(直接上書き):",
"旧格式必须是 JSON 对象": "レガシー形式はJSONオブジェクトである必要があります",
@@ -1660,6 +1673,8 @@
"服务可用性": "サービスの可用性",
"服务商": "Service Provider",
"服务器地址": "サーバーURL",
"服务器日志功能未启用(未配置日志目录)": "サーバーログ機能が有効になっていません(ログディレクトリが未設定)",
"服务器日志管理": "サーバーログ管理",
"服务显示名称": "サービス表示名",
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "一致するモデルが見つかりません。Enterキーで「{{name}}」をカスタムモデル名として追加できます。",
"未发现新增模型": "追加された新規モデルはありません",
@@ -1949,6 +1964,8 @@
"添加额度": "残高追加",
"清理不活跃缓存": "非アクティブなキャッシュをクリーンアップ",
"清理失败": "クリーンアップに失敗しました",
"清理方式": "クリーンアップモード",
"清理日志文件": "ログファイルをクリーンアップ",
"清空": "Clear",
"清空全部缓存": "すべてのキャッシュをクリア",
"清空该规则缓存": "このルールのキャッシュをクリア",
@@ -2148,6 +2165,7 @@
"确认清空全部渠道亲和性缓存": "すべてのチャネルアフィニティキャッシュのクリアを確認",
"确认清空该规则缓存": "このルールのキャッシュクリアを確認",
"确认清除历史日志": "履歴のクリアの確認",
"确认清理日志文件?": "ログファイルのクリーンアップを確認しますか?",
"确认禁用": "無効化の確認",
"确认补单": "手動チャージの確認",
"确认解绑": "連携解除の確認",
@@ -2246,6 +2264,7 @@
"管理模型、标签、端点等预填组": "モデル、タグ、エンドポイントなどの事前入力グループ管理",
"管理用户已绑定的第三方账户,支持筛选与解绑": "ユーザーにリンクされたサードパーティアカウントを管理、フィルタリングとバインド解除をサポート",
"管理绑定": "バインド管理",
"管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。": "サーバーログファイルを管理します。ログファイルは時間とともに蓄積されるため、定期的なクリーンアップでディスク容量を解放することをお勧めします。",
"类型": "タイプ",
"类型(常用)": "タイプ(一般的)",
"粘贴图片失败": "画像の貼り付けに失敗しました",
@@ -2695,6 +2714,7 @@
"请输入新的部署名称": "Please enter new deployment name",
"请输入显示名称": "表示名を入力してください",
"请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "有効なJSON形式のリクエストボディを入力してください。プレビューパネルのデフォルトのリクエストボディ形式を参照できます。",
"请输入有效的数值": "有効な値を入力してください",
"请输入有效的数字": "有効な数値を入力してください",
"请输入有效的镜像地址": "Please enter a valid image address",
"请输入标签名称": "タグ名を入力してください",
@@ -3127,6 +3147,12 @@
"高级设置": "詳細設定",
"高级选项": "高度なオプション",
"高级配置": "Advanced Configuration",
"核心配置": "基本設定",
"创建渠道所需的基本信息": "チャネル作成に必要な基本情報",
"请求配置": "リクエスト設定",
"渠道行为": "チャネル動作",
"额外设置": "追加設定",
"上游模型管理": "上流モデル管理",
"黑名单": "ブラックリスト",
"默认": "デフォルト",
"默认 API 版本": "デフォルトAPIバージョン",
@@ -3263,6 +3289,15 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "入力価格:{{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "補完料金 {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "補完料金:{{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "補完料金:{{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "補完料金:{{symbol}}{{total}} / 1M tokens",
"复制密钥": "キーをコピー",
"复制连接信息": "接続情報をコピー",
"检测到剪贴板中的连接信息": "クリップボードに接続情報が検出されました",
"自动填入": "自動入力",
"忽略": "無視",
"从剪贴板粘贴配置": "クリップボードから貼り付け",
"剪贴板中未检测到连接信息": "クリップボードに接続情報が見つかりません",
"连接信息已填入": "接続情報を入力しました",
"无法读取剪贴板": "クリップボードを読み取れません"
}
}
+38 -3
View File
@@ -501,6 +501,10 @@
"保存邮箱域名白名单设置": "Сохранить настройки белого списка доменов email",
"保存额度设置": "Сохранить настройки лимитов",
"保留原值(目标已有值时不覆盖)": "Сохранить исходное значение (не перезаписывать, если цель уже имеет значение)",
"保留天数": "Дней для хранения",
"保留文件数": "Файлов для хранения",
"保留最近N个文件": "Хранить последние N файлов",
"保留最近N天": "Хранить за последние N дней",
"修复数据库一致性": "Исправить согласованность базы данных",
"修改为": "Изменить на",
"修改子渠道优先级": "Изменить приоритет дочерних каналов",
@@ -930,7 +934,7 @@
"在此输入系统名称": "Введите здесь название системы",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Введите здесь содержимое политики конфиденциальности, поддерживается Markdown & HTML код",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "Введите здесь содержание главной страницы, поддерживается код Markdown и HTML. После настройки информация о состоянии на главной странице больше не будет отображаться. Если введена ссылка, она будет использована как атрибут src для iframe, что позволяет установить любую веб-страницу как главную страницу",
"域名IP过滤详细说明": "⚠️ Эта функция является экспериментальной опцией, доменное имя может быть разрешено в несколько адресов IPv4/IPv6, если включено, убедитесь, что список фильтрации IP покрывает эти адреса, иначе это может привести к сбою доступа.",
"域名IP过滤详细说明": "Рекомендуется: при включении домены разрешаются через DNS, а полученные IP-адреса проверяются на принадлежность к частным диапазонам, что эффективно предотвращает атаки DNS rebinding, обходящие защиту SSRF. Примечание: домен может разрешаться в несколько адресов IPv4/IPv6. Если вы настроили список фильтрации IP, убедитесь, что он покрывает эти адреса, иначе доступ может быть нарушен.",
"域名白名单": "Белый список доменов",
"域名黑名单": "Чёрный список доменов",
"基本信息": "Основная информация",
@@ -1099,7 +1103,7 @@
"密钥预览": "Предпросмотр ключа",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "Для официальных каналов new-api уже имеет встроенные адреса, если это не сторонние прокси-сайты или специальные адреса доступа Azure, заполнять не нужно",
"对免费模型启用预消耗": "Включить предварительное списание для бесплатных моделей",
"对域名启用 IP 过滤(实验性": "Включить IP-фильтрацию для доменов (экспериментально)",
"对域名启用 IP 过滤(推荐开启": "Включить IP-фильтрацию для доменов (рекомендуется)",
"对外运营模式": "Режим внешней эксплуатации",
"对象清理规则": "Правила очистки объектов",
"导入": "Импорт",
@@ -1116,6 +1120,8 @@
"将删除": "Будет удалено",
"将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "Будут удалены использованные, отключенные и просроченные коды обмена, эта операция необратима.",
"将删除所有仍在内存中的渠道亲和性缓存条目。": "Будут удалены все записи кэша аффинити каналов, оставшиеся в памяти.",
"将删除 {{value}} 天前的日志文件。": "Файлы журналов старше {{value}} дней будут удалены.",
"将只保留最近 {{value}} 个日志文件,其余将被删除。": "Будут сохранены только последние {{value}} файлов журналов; остальные будут удалены.",
"将大请求体临时存储到磁盘": "Временное сохранение больших тел запросов на диск",
"将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?": "Будут очищены все сохраненные конфигурации и восстановлены настройки по умолчанию, эта операция необратима. Продолжить?",
"将清除选定时间之前的所有日志": "Будут очищены все логи до выбранного времени",
@@ -1212,6 +1218,7 @@
"已添加到白名单": "Добавлено в белый список",
"已清空": "Очищено",
"已清空测试结果": "Результаты тестов очищены",
"已清理 {{count}} 个日志文件,释放 {{size}}": "Очищено {{count}} файлов журналов, освобождено {{size}}",
"已生成授权凭据": "Учётные данные авторизации сгенерированы",
"已用": "Used",
"已用/剩余": "Использовано/Осталось",
@@ -1500,6 +1507,8 @@
"收益统计": "Статистика доходов",
"收起": "Свернуть",
"收起侧边栏": "Свернуть боковую панель",
"向左展开": "Развернуть влево",
"向右展开": "Развернуть вправо",
"收起内容": "Свернуть содержимое",
"放大": "Увеличить",
"放大编辑": "Увеличить и редактировать",
@@ -1575,6 +1584,10 @@
"日志类型": "Тип журнала",
"日志设置": "Настройки журнала",
"日志详情": "Детали журнала",
"日志目录": "Каталог журналов",
"日志文件数": "Количество файлов журналов",
"日志时间范围": "Диапазон дат журналов",
"日志总大小": "Общий размер журналов",
"旧格式(JSON 对象)": "Устаревший формат (JSON-объект)",
"旧格式(直接覆盖):": "Старый формат (прямая перезапись):",
"旧格式必须是 JSON 对象": "Устаревший формат должен быть JSON-объектом",
@@ -1689,6 +1702,8 @@
"服务可用性": "Доступность сервиса",
"服务商": "Service Provider",
"服务器地址": "Адрес сервера",
"服务器日志功能未启用(未配置日志目录)": "Ведение журнала сервера не включено (каталог журналов не настроен)",
"服务器日志管理": "Управление журналами сервера",
"服务显示名称": "Отображаемое имя сервиса",
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "Совпадающих моделей не найдено. Нажмите Enter, чтобы добавить «{{name}}» как пользовательское имя модели.",
"未发现新增模型": "Новые модели не обнаружены",
@@ -1978,6 +1993,8 @@
"添加额度": "Добавить лимит",
"清理不活跃缓存": "Очистить неактивный кэш",
"清理失败": "Ошибка очистки",
"清理方式": "Режим очистки",
"清理日志文件": "Очистить файлы журналов",
"清空": "Clear",
"清空全部缓存": "Очистить весь кэш",
"清空该规则缓存": "Очистить кэш этого правила",
@@ -2181,6 +2198,7 @@
"确认清空全部渠道亲和性缓存": "Подтвердить очистку всего кэша аффинити каналов",
"确认清空该规则缓存": "Подтвердить очистку кэша этого правила",
"确认清除历史日志": "Подтвердить очистку истории логов",
"确认清理日志文件?": "Подтвердить очистку файлов журналов?",
"确认禁用": "Подтвердить отключение",
"确认补单": "Подтвердить дополнение заказа",
"确认解绑": "Подтвердить отвязку",
@@ -2279,6 +2297,7 @@
"管理模型、标签、端点等预填组": "Управление предзаполненными группами моделей, тегов, конечных точек и т.д.",
"管理用户已绑定的第三方账户,支持筛选与解绑": "Управление привязанными сторонними аккаунтами пользователей с поддержкой фильтрации и отвязки",
"管理绑定": "Управление привязками",
"管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。": "Управление файлами журналов сервера. Файлы журналов накапливаются со временем; рекомендуется регулярная очистка для освобождения дискового пространства.",
"类型": "Тип",
"类型(常用)": "Тип (часто используемые)",
"粘贴图片失败": "Ошибка вставки изображения",
@@ -2728,6 +2747,7 @@
"请输入新的部署名称": "Please enter new deployment name",
"请输入显示名称": "Пожалуйста, введите отображаемое имя",
"请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "Пожалуйста, введите тело запроса в действительном формате JSON. Вы можете обратиться к формату тела запроса по умолчанию на панели предварительного просмотра.",
"请输入有效的数值": "Пожалуйста, введите допустимое значение",
"请输入有效的数字": "Пожалуйста, введите действительное число",
"请输入有效的镜像地址": "Please enter a valid image address",
"请输入标签名称": "Пожалуйста, введите имя тега",
@@ -3160,6 +3180,12 @@
"高级设置": "Расширенные настройки",
"高级选项": "Расширенные параметры",
"高级配置": "Advanced Configuration",
"核心配置": "Основные настройки",
"创建渠道所需的基本信息": "Основная информация для создания канала",
"请求配置": "Настройки запросов",
"渠道行为": "Поведение канала",
"额外设置": "Дополнительные настройки",
"上游模型管理": "Управление моделями апстрима",
"黑名单": "Черный список",
"默认": "По умолчанию",
"默认 API 版本": "Версия API по умолчанию",
@@ -3296,6 +3322,15 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "Цена ввода: {{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "Цена вывода {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "Цена вывода: {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "Цена вывода: {{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "Цена вывода: {{symbol}}{{total}} / 1M tokens",
"复制密钥": "Копировать ключ",
"复制连接信息": "Копировать данные подключения",
"检测到剪贴板中的连接信息": "В буфере обмена обнаружены данные подключения",
"自动填入": "Заполнить",
"忽略": "Игнорировать",
"从剪贴板粘贴配置": "Вставить конфигурацию",
"剪贴板中未检测到连接信息": "Данные подключения не найдены в буфере обмена",
"连接信息已填入": "Данные подключения применены",
"无法读取剪贴板": "Не удалось прочитать буфер обмена"
}
}
+38 -3
View File
@@ -495,6 +495,10 @@
"保存邮箱域名白名单设置": "Lưu cài đặt danh sách trắng tên miền email",
"保存额度设置": "Lưu cài đặt hạn ngạch",
"保留原值(目标已有值时不覆盖)": "Giữ giá trị gốc (không ghi đè nếu mục tiêu đã có giá trị)",
"保留天数": "Số ngày giữ lại",
"保留文件数": "Số tệp giữ lại",
"保留最近N个文件": "Giữ lại N tệp gần nhất",
"保留最近N天": "Giữ lại N ngày gần nhất",
"修复数据库一致性": "Sửa chữa tính nhất quán của cơ sở dữ liệu",
"修改为": "Sửa đổi thành",
"修改子渠道优先级": "Sửa đổi ưu tiên kênh phụ",
@@ -916,7 +920,7 @@
"在此输入系统名称": "Nhập tên hệ thống tại đây",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Nhập nội dung chính sách bảo mật tại đây, hỗ trợ mã Markdown & HTML",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "Nhập nội dung trang chủ tại đây, hỗ trợ Markdown",
"域名IP过滤详细说明": "⚠️ Đây là tùy chọn thử nghiệm. Một tên miền có thể phân giải thành nhiều địa chỉ IPv4/IPv6. Nếu bật, hãy đảm bảo danh sách lọc IP bao gồm các địa chỉ này, nếu không truy cập có thể thất bại.",
"域名IP过滤详细说明": "Khuyến nghị: Khi được bật, tên miền sẽ được phân giải DNS và các IP kết quả sẽ được kiểm tra xem có thuộc dải địa chỉ riêng tư hay không, ngăn chặn hiệu quả các cuộc tấn công DNS rebinding vượt qua bảo vệ SSRF. Lưu ý: Một tên miền có thể phân giải thành nhiều địa chỉ IPv4/IPv6. Nếu bạn đã cấu hình danh sách lọc IP, hãy đảm bảo nó bao gồm các địa chỉ này, nếu không truy cập có thể thất bại.",
"域名白名单": "Danh sách trắng tên miền",
"域名黑名单": "Danh sách đen tên miền",
"基本信息": "Thông tin cơ bản",
@@ -1085,7 +1089,7 @@
"密钥预览": "Xem trước khóa",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "Đối với các kênh chính thức, new-api đã tích hợp sẵn địa chỉ. Trừ khi đó là trang web proxy của bên thứ ba hoặc địa chỉ truy cập đặc biệt của Azure, không cần điền vào",
"对免费模型启用预消耗": "Enable pre-consumption for free models",
"对域名启用 IP 过滤(实验性": "Bật lọc IP cho tên miền (thử nghiệm)",
"对域名启用 IP 过滤(推荐开启": "Bật lọc IP cho tên miền (khuyến ngh)",
"对外运营模式": "Chế độ mặc định",
"对象清理规则": "Quy tắc dọn dẹp đối tượng",
"导入": "Nhập",
@@ -1102,6 +1106,8 @@
"将删除": "Đang xóa",
"将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "Thao tác này sẽ xóa tất cả các mã đổi thưởng đã sử dụng, bị vô hiệu hóa và hết hạn, thao tác này không thể hoàn tác.",
"将删除所有仍在内存中的渠道亲和性缓存条目。": "Sẽ xóa tất cả mục bộ nhớ đệm ưu ái kênh còn trong bộ nhớ.",
"将删除 {{value}} 天前的日志文件。": "Tệp nhật ký cũ hơn {{value}} ngày sẽ bị xóa.",
"将只保留最近 {{value}} 个日志文件,其余将被删除。": "Chỉ giữ lại {{value}} tệp nhật ký gần nhất; phần còn lại sẽ bị xóa.",
"将大请求体临时存储到磁盘": "Lưu tạm body yêu cầu lớn vào đĩa",
"将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?": "Thao tác này sẽ xóa tất cả các cấu hình đã lưu và khôi phục cài đặt mặc định, thao tác này không thể hoàn tác. Tiếp tục?",
"将清除选定时间之前的所有日志": "Thao tác này sẽ xóa tất cả nhật ký trước thời gian đã chọn",
@@ -1186,6 +1192,7 @@
"已添加到白名单": "Đã thêm vào danh sách trắng",
"已清空": "Đã xóa sạch",
"已清空测试结果": "Đã xóa kết quả kiểm tra",
"已清理 {{count}} 个日志文件,释放 {{size}}": "Đã dọn dẹp {{count}} tệp nhật ký, giải phóng {{size}}",
"已生成授权凭据": "Đã tạo thông tin xác thực",
"已用": "Used",
"已用/剩余": "Đã dùng/Còn lại",
@@ -1472,6 +1479,8 @@
"收益统计": "Thống kê thu nhập",
"收起": "Thu gọn",
"收起侧边栏": "Thu gọn thanh bên",
"向左展开": "Mở rộng sang trái",
"向右展开": "Mở rộng sang phải",
"收起内容": "Thu gọn nội dung",
"放大": "Upscalers",
"放大编辑": "Mở rộng trình chỉnh sửa",
@@ -1547,6 +1556,10 @@
"日志类型": "Loại nhật ký",
"日志设置": "Cài đặt nhật ký",
"日志详情": "Chi tiết nhật ký",
"日志目录": "Thư mục nhật ký",
"日志文件数": "Số lượng tệp nhật ký",
"日志时间范围": "Phạm vi thời gian nhật ký",
"日志总大小": "Tổng kích thước nhật ký",
"旧格式(JSON 对象)": "Định dạng cũ (Đối tượng JSON)",
"旧格式(直接覆盖):": "Định dạng cũ (ghi đè trực tiếp):",
"旧格式必须是 JSON 对象": "Định dạng cũ phải là đối tượng JSON",
@@ -1661,6 +1674,8 @@
"服务可用性": "Trạng thái dịch vụ",
"服务商": "Service Provider",
"服务器地址": "Địa chỉ máy chủ",
"服务器日志功能未启用(未配置日志目录)": "Ghi nhật ký máy chủ chưa được bật (chưa cấu hình thư mục nhật ký)",
"服务器日志管理": "Quản lý nhật ký máy chủ",
"服务显示名称": "Tên hiển thị dịch vụ",
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "Không tìm thấy mô hình khớp. Nhấn Enter để thêm \"{{name}}\" làm tên mô hình tùy chỉnh.",
"未发现新增模型": "Không có mô hình mới nào được thêm",
@@ -2047,6 +2062,8 @@
"清理不活跃缓存": "Xóa cache không hoạt động",
"清理历史日志": "Dọn dẹp nhật ký lịch sử",
"清理失败": "Dọn dẹp thất bại",
"清理方式": "Chế độ dọn dẹp",
"清理日志文件": "Dọn dẹp tệp nhật ký",
"清理成功": "Dọn dẹp thành công",
"清理数据": "Dọn dẹp dữ liệu",
"清理日志": "Dọn dẹp nhật ký",
@@ -2382,6 +2399,7 @@
"确认清空该规则缓存": "Xác nhận xóa bộ nhớ đệm của quy tắc này",
"确认清除": "Xác nhận xóa",
"确认清除历史日志": "Xác nhận xóa nhật ký lịch sử",
"确认清理日志文件?": "Xác nhận dọn dẹp tệp nhật ký?",
"确认禁用": "Xác nhận vô hiệu hóa",
"确认补单": "Xác nhận hoàn thành đơn hàng",
"确认解绑": "Xác nhận hủy liên kết",
@@ -2518,6 +2536,7 @@
"管理用户": "Quản lý người dùng",
"管理用户已绑定的第三方账户,支持筛选与解绑": "Quản lý tài khoản bên thứ ba đã liên kết của người dùng, hỗ trợ lọc và hủy liên kết",
"管理绑定": "Quản lý liên kết",
"管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。": "Quản lý tệp nhật ký máy chủ. Tệp nhật ký tích lũy theo thời gian; nên dọn dẹp định kỳ để giải phóng dung lượng đĩa.",
"管理面板": "Bảng quản lý",
"类": "Lớp",
"类型": "Loại",
@@ -3120,6 +3139,7 @@
"请输入旧密码": "Vui lòng nhập mật khẩu cũ",
"请输入显示名称": "Vui lòng nhập tên hiển thị",
"请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "Vui lòng nhập nội dung yêu cầu có định dạng JSON hợp lệ. Bạn có thể tham khảo định dạng nội dung yêu cầu mặc định trong bảng xem trước.",
"请输入有效的数值": "Vui lòng nhập giá trị hợp lệ",
"请输入有效的数字": "Vui lòng nhập số hợp lệ",
"请输入有效的镜像地址": "Please enter a valid image address",
"请输入标签名称": "Vui lòng nhập tên thẻ",
@@ -3698,6 +3718,12 @@
"高级设置": "Cài đặt nâng cao",
"高级选项": "Tùy chọn nâng cao",
"高级配置": "Advanced Configuration",
"核心配置": "Cấu hình cốt lõi",
"创建渠道所需的基本信息": "Thông tin cơ bản cần thiết để tạo kênh",
"请求配置": "Cấu hình yêu cầu",
"渠道行为": "Hành vi kênh",
"额外设置": "Cài đặt bổ sung",
"上游模型管理": "Quản lý mô hình thượng nguồn",
"黑名单": "Danh sách đen",
"默认": "Mặc định",
"默认 API 版本": "Phiên bản API mặc định",
@@ -3833,6 +3859,15 @@
"补全倍率 {{completionRatio}}": "Tỷ lệ hoàn thành {{completionRatio}}",
"输出价格 {{symbol}}{{price}} / 1M tokens": "Giá đầu ra {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "Giá đầu ra: {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "Giá đầu ra: {{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "Giá đầu ra: {{symbol}}{{total}} / 1M tokens",
"复制密钥": "Sao chép khóa",
"复制连接信息": "Sao chép thông tin kết nối",
"检测到剪贴板中的连接信息": "Phát hiện thông tin kết nối trong bộ nhớ tạm",
"自动填入": "Tự động điền",
"忽略": "Bỏ qua",
"从剪贴板粘贴配置": "Dán cấu hình",
"剪贴板中未检测到连接信息": "Không tìm thấy thông tin kết nối trong bộ nhớ tạm",
"连接信息已填入": "Đã áp dụng thông tin kết nối",
"无法读取剪贴板": "Không thể đọc bộ nhớ tạm"
}
}
+30 -3
View File
@@ -388,6 +388,10 @@
"保存通用设置": "保存通用设置",
"保存邮箱域名白名单设置": "保存邮箱域名白名单设置",
"保存额度设置": "保存额度设置",
"保留天数": "保留天数",
"保留文件数": "保留文件数",
"保留最近N个文件": "保留最近N个文件",
"保留最近N天": "保留最近N天",
"修复数据库一致性": "修复数据库一致性",
"修改为": "修改为",
"修改子渠道优先级": "修改子渠道优先级",
@@ -731,7 +735,7 @@
"在此输入系统名称": "在此输入系统名称",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "在此输入隐私政策内容,支持 Markdown & HTML 代码",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页",
"域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。",
"域名IP过滤详细说明": "推荐开启:开启后会对域名进行 DNS 解析并检查解析后的 IP 是否为私有地址,可有效防止 DNS 重绑定攻击绕过 SSRF 防护。注意:域名可能解析到多个 IPv4/IPv6 地址,若配置了 IP 过滤列表,请确保覆盖这些地址,否则可能导致访问失败。",
"域名白名单": "域名白名单",
"域名黑名单": "域名黑名单",
"基本信息": "基本信息",
@@ -866,7 +870,7 @@
"密钥预览": "密钥预览",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写",
"对免费模型启用预消耗": "对免费模型启用预消耗",
"对域名启用 IP 过滤(实验性": "对域名启用 IP 过滤(实验性",
"对域名启用 IP 过滤(推荐开启": "对域名启用 IP 过滤(推荐开启",
"对外运营模式": "对外运营模式",
"导入": "导入",
"导入的配置将覆盖当前设置,是否继续?": "导入的配置将覆盖当前设置,是否继续?",
@@ -880,7 +884,9 @@
"将为选中的 ": "将为选中的 ",
"将仅保留第一个密钥文件,其余文件将被移除,是否继续?": "将仅保留第一个密钥文件,其余文件将被移除,是否继续?",
"将删除": "将删除",
"将删除 {{value}} 天前的日志文件。": "将删除 {{value}} 天前的日志文件。",
"将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "将删除已使用、已禁用及过期的兑换码,此操作不可撤销。",
"将只保留最近 {{value}} 个日志文件,其余将被删除。": "将只保留最近 {{value}} 个日志文件,其余将被删除。",
"将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?": "将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?",
"将清除选定时间之前的所有日志": "将清除选定时间之前的所有日志",
"小时": "小时",
@@ -944,6 +950,7 @@
"已注销": "已注销",
"已添加": "已添加",
"已添加到白名单": "已添加到白名单",
"已清理 {{count}} 个日志文件,释放 {{size}}": "已清理 {{count}} 个日志文件,释放 {{size}}",
"已清空测试结果": "已清空测试结果",
"已用": "已用",
"已用/剩余": "已用/剩余",
@@ -1240,8 +1247,12 @@
"日志已下载": "日志已下载",
"日志已加载": "日志已加载",
"日志已复制到剪贴板": "日志已复制到剪贴板",
"日志时间范围": "日志时间范围",
"日志总大小": "日志总大小",
"日志文件数": "日志文件数",
"日志流": "日志流",
"日志清理失败:": "日志清理失败:",
"日志目录": "日志目录",
"日志类型": "日志类型",
"日志设置": "日志设置",
"日志详情": "日志详情",
@@ -1340,6 +1351,8 @@
"服务可用性": "服务可用性",
"服务商": "服务商",
"服务器地址": "服务器地址",
"服务器日志功能未启用(未配置日志目录)": "服务器日志功能未启用(未配置日志目录)",
"服务器日志管理": "服务器日志管理",
"服务显示名称": "服务显示名称",
"未发现新增模型": "未发现新增模型",
"未发现重复密钥": "未发现重复密钥",
@@ -1591,6 +1604,8 @@
"添加键值对": "添加键值对",
"添加问答": "添加问答",
"添加额度": "添加额度",
"清理方式": "清理方式",
"清理日志文件": "清理日志文件",
"清空": "清空",
"清空重定向": "清空重定向",
"清除历史日志": "清除历史日志",
@@ -1756,6 +1771,7 @@
"确认延长容器时长": "确认延长容器时长",
"确认操作": "确认操作",
"确认新密码": "确认新密码",
"确认清理日志文件?": "确认清理日志文件?",
"确认清除历史日志": "确认清除历史日志",
"确认禁用": "确认禁用",
"确认补单": "确认补单",
@@ -1817,6 +1833,7 @@
"管理员账号": "管理员账号",
"管理员账号已经初始化过,请继续设置其他参数": "管理员账号已经初始化过,请继续设置其他参数",
"管理模型、标签、端点等预填组": "管理模型、标签、端点等预填组",
"管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。": "管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。",
"类型": "类型",
"粘贴图片失败": "粘贴图片失败",
"精确": "精确",
@@ -2211,6 +2228,7 @@
"请输入新的部署名称": "请输入新的部署名称",
"请输入显示名称": "请输入显示名称",
"请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。",
"请输入有效的数值": "请输入有效的数值",
"请输入有效的数字": "请输入有效的数字",
"请输入有效的镜像地址": "请输入有效的镜像地址",
"请输入标签名称": "请输入标签名称",
@@ -2938,6 +2956,15 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "输入价格:{{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "输出价格 {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "输出价格:{{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens",
"复制密钥": "复制密钥",
"复制连接信息": "复制连接信息",
"检测到剪贴板中的连接信息": "检测到剪贴板中的连接信息",
"自动填入": "自动填入",
"忽略": "忽略",
"从剪贴板粘贴配置": "从剪贴板粘贴配置",
"剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息",
"连接信息已填入": "连接信息已填入",
"无法读取剪贴板": "无法读取剪贴板"
}
}
+38 -3
View File
@@ -389,6 +389,10 @@
"保存通用设置": "儲存通用設定",
"保存邮箱域名白名单设置": "儲存信箱域名白名單設定",
"保存额度设置": "儲存額度設定",
"保留天数": "保留天數",
"保留文件数": "保留檔案數",
"保留最近N个文件": "保留最近N個檔案",
"保留最近N天": "保留最近N天",
"修复数据库一致性": "修復資料庫一致性",
"修改为": "修改為",
"修改子渠道优先级": "修改子管道優先級",
@@ -733,7 +737,7 @@
"在此输入系统名称": "在此輸入系統名稱",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "在此輸入隱私政策內容,支援 Markdown & HTML 程式碼",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "在此輸入首頁內容,支援 Markdown & HTML 程式碼,設定後首頁的狀態訊息將不再顯示。如果輸入的是一個連結,則會使用該連結作為 iframe 的 src 屬性,這允許你設定任意網頁作為首頁",
"域名IP过滤详细说明": "⚠️此功能為實驗性選項,域名可能解析到多個 IPv4/IPv6 位址,若開啟,請確保 IP 過濾列表覆蓋這些位址,否則可能導致訪問失敗。",
"域名IP过滤详细说明": "建議開啟:開啟後會對域名進行 DNS 解析並檢查解析後的 IP 是否為私有位址,可有效防止 DNS 重綁定攻擊繞過 SSRF 防護。注意:域名可能解析到多個 IPv4/IPv6 位址,若配置了 IP 過濾列表,請確保覆蓋這些位址,否則可能導致訪問失敗。",
"域名白名单": "域名白名單",
"域名黑名单": "域名黑名單",
"基本信息": "基本資訊",
@@ -869,7 +873,7 @@
"密钥预览": "密鑰預覽",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "對於官方管道,new-api已經內置位址,除非是第三方代理站點或者Azure的特殊接入位址,否則不需要填寫",
"对免费模型启用预消耗": "對免費模型啟用預消耗",
"对域名启用 IP 过滤(实验性": "對域名啟用 IP 過濾(實驗性",
"对域名启用 IP 过滤(推荐开启": "對域名啟用 IP 過濾(建議開啟",
"对外运营模式": "對外運營模式",
"导入": "導入",
"导入的配置将覆盖当前设置,是否继续?": "導入的設定將覆蓋當前設定,是否繼續?",
@@ -882,7 +886,9 @@
"将 reasoning_content 转换为 <think> 标签拼接到内容中": "將 reasoning_content 轉換為 <think> 標籤拼接到內容中",
"将为选中的 ": "將為選中的 ",
"将仅保留第一个密钥文件,其余文件将被移除,是否继续?": "將僅保留第一個密鑰檔案,其餘檔案將被移除,是否繼續?",
"将只保留最近 {{value}} 个日志文件,其余将被删除。": "將只保留最近 {{value}} 個日誌檔案,其餘將被刪除。",
"将删除": "將刪除",
"将删除 {{value}} 天前的日志文件。": "將刪除 {{value}} 天前的日誌檔案。",
"将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "將刪除已使用、已禁用及過期的兌換碼,此操作不可撤銷。",
"将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?": "將清除所有儲存的設定並恢復預設設定,此操作不可撤銷。是否繼續?",
"将清除选定时间之前的所有日志": "將清除選定時間之前的所有日誌",
@@ -947,6 +953,7 @@
"已注销": "已註銷",
"已添加": "已添加",
"已添加到白名单": "已添加到白名單",
"已清理 {{count}} 个日志文件,释放 {{size}}": "已清理 {{count}} 個日誌檔案,釋放 {{size}}",
"已清空测试结果": "已清空測試結果",
"已用": "已用",
"已用/剩余": "已用/剩餘",
@@ -1183,6 +1190,8 @@
"收益统计": "收益統計",
"收起": "收起",
"收起侧边栏": "收起側邊欄",
"向左展开": "向左展開",
"向右展开": "向右展開",
"收起内容": "收起內容",
"放大": "放大",
"放大编辑": "放大編輯",
@@ -1243,8 +1252,12 @@
"日志已下载": "日誌已下載",
"日志已加载": "日誌已載入",
"日志已复制到剪贴板": "日誌已複製到剪貼板",
"日志时间范围": "日誌時間範圍",
"日志总大小": "日誌總大小",
"日志文件数": "日誌檔案數",
"日志流": "日誌流",
"日志清理失败:": "日誌清理失敗:",
"日志目录": "日誌目錄",
"日志类型": "日誌類型",
"日志设置": "日誌設定",
"日志详情": "日誌詳情",
@@ -1344,6 +1357,8 @@
"服务可用性": "服務可用性",
"服务商": "服務商",
"服务器地址": "伺服器位址",
"服务器日志功能未启用(未配置日志目录)": "伺服器日誌功能未啟用(未配置日誌目錄)",
"服务器日志管理": "伺服器日誌管理",
"服务显示名称": "服務顯示名稱",
"未发现新增模型": "未發現新增模型",
"未发现重复密钥": "未發現重複密鑰",
@@ -1597,6 +1612,8 @@
"添加键值对": "添加鍵值對",
"添加问答": "添加問答",
"添加额度": "添加額度",
"清理方式": "清理方式",
"清理日志文件": "清理日誌檔案",
"清空": "清空",
"清空重定向": "清空重定向",
"清除历史日志": "清除歷史日誌",
@@ -1762,6 +1779,7 @@
"确认延长容器时长": "確認延長容器時長",
"确认操作": "確認操作",
"确认新密码": "確認新密碼",
"确认清理日志文件?": "確認清理日誌檔案?",
"确认清除历史日志": "確認清除歷史日誌",
"确认禁用": "確認禁用",
"确认补单": "確認補單",
@@ -1823,6 +1841,7 @@
"管理员设置了外部链接,点击下方按钮访问": "管理員設定了外部連結,點擊下方按鈕訪問",
"管理员账号": "管理員帳號",
"管理员账号已经初始化过,请继续设置其他参数": "管理員帳號已經初始化過,請繼續設定其他參數",
"管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。": "管理伺服器運行日誌檔案。日誌檔案會隨運行時間不斷累積,建議定期清理以釋放磁碟空間。",
"管理模型、标签、端点等预填组": "管理模型、標籤、端點等預填組",
"类型": "類型",
"粘贴图片失败": "貼上圖片失敗",
@@ -2219,6 +2238,7 @@
"请输入新的部署名称": "請輸入新的部署名稱",
"请输入显示名称": "請輸入顯示名稱",
"请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "請輸入有效的JSON格式的請求體。您可以參考預覽面板中的預設請求體格式。",
"请输入有效的数值": "請輸入有效的數值",
"请输入有效的数字": "請輸入有效的數位",
"请输入有效的镜像地址": "請輸入有效的鏡像位址",
"请输入标签名称": "請輸入標籤名稱",
@@ -2635,6 +2655,12 @@
"验证配置错误": "驗證設定錯誤",
"高级设置": "進階設定",
"高级配置": "進階設定",
"核心配置": "核心設定",
"创建渠道所需的基本信息": "建立頻道所需的基本資訊",
"请求配置": "請求設定",
"渠道行为": "頻道行為",
"额外设置": "額外設定",
"上游模型管理": "上游模型管理",
"黑名单": "黑名單",
"默认": "預設",
"默认 API 版本": "預設 API 版本",
@@ -2947,6 +2973,15 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "輸入價格:{{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "輸出價格 {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "輸出價格:{{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens",
"复制密钥": "複製金鑰",
"复制连接信息": "複製連線資訊",
"检测到剪贴板中的连接信息": "偵測到剪貼簿中的連線資訊",
"自动填入": "自動填入",
"忽略": "忽略",
"从剪贴板粘贴配置": "從剪貼簿貼上設定",
"剪贴板中未检测到连接信息": "剪貼簿中未偵測到連線資訊",
"连接信息已填入": "連線資訊已填入",
"无法读取剪贴板": "無法讀取剪貼簿"
}
}
+35
View File
@@ -31,6 +31,13 @@ body {
background-color: var(--semi-color-bg-0);
}
/* 桌面端禁止 body 纵向滚动 - 防止 VChart tooltip 触发页面滚动条 */
@media (min-width: 768px) {
body {
overflow-y: hidden;
}
}
.app-layout {
height: 100vh;
height: 100dvh;
@@ -469,6 +476,9 @@ html.dark .sbg-variant-green {
.custom-footer {
font-size: 1.1em;
}
.custom-footer.na-cb6feafeb3990c78 {
position: relative;
}
/* 卡片内容容器通用样式 */
.card-content-container {
@@ -971,3 +981,28 @@ html.dark .with-pastel-balls::before {
.semi-datepicker-range-input {
border-radius: 10px !important;
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Channel advanced settings side panel animations */
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.ec-dbcd0a3c01b55203 { forced-color-adjust: auto; }
@@ -539,6 +539,15 @@ export default function SettingsChannelAffinity(props) {
dataIndex: 'ttl_seconds',
render: (v) => <Text>{Number(v || 0) || '-'}</Text>,
},
{
title: t('失败后不重试'),
dataIndex: 'skip_retry_on_failure',
render: (value) => (
<Tag color={value ? 'orange' : 'grey'} style={{ marginRight: 4 }}>
{value ? t('是') : t('否')}
</Tag>
),
},
{
title: t('覆盖模板'),
render: (_, record) => {
@@ -1096,6 +1105,18 @@ export default function SettingsChannelAffinity(props) {
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 12 }}>
<Col xs={24} sm={12}>
<Form.Switch
field='skip_retry_on_failure'
label={t('失败后不重试')}
/>
<Text type='tertiary' size='small'>
{t('开启后,若该规则命中且请求失败,将不会切换渠道重试。')}
</Text>
</Col>
</Row>
<Collapse
keepDOM
activeKey={modalAdvancedActiveKey}
@@ -1251,18 +1272,6 @@ export default function SettingsChannelAffinity(props) {
</Text>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Switch
field='skip_retry_on_failure'
label={t('失败后不重试')}
/>
<Text type='tertiary' size='small'>
{t('开启后,若该规则命中且请求失败,将不会切换渠道重试。')}
</Text>
</Col>
</Row>
</Collapse.Panel>
</Collapse>
@@ -23,12 +23,15 @@ import {
Button,
Col,
Form,
InputNumber,
Row,
Spin,
Progress,
Descriptions,
Tag,
Popconfirm,
RadioGroup,
Radio,
Typography,
} from '@douyinfe/semi-ui';
import {
@@ -68,10 +71,14 @@ export default function SettingsPerformance(props) {
'performance_setting.monitor_enabled': false,
'performance_setting.monitor_cpu_threshold': 90,
'performance_setting.monitor_memory_threshold': 90,
'performance_setting.monitor_disk_threshold': 90,
'performance_setting.monitor_disk_threshold': 95,
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
const [logInfo, setLogInfo] = useState(null);
const [logCleanupMode, setLogCleanupMode] = useState('by_count');
const [logCleanupValue, setLogCleanupValue] = useState(10);
const [logCleanupLoading, setLogCleanupLoading] = useState(false);
function handleFieldChange(fieldName) {
return (value) => {
@@ -167,6 +174,46 @@ export default function SettingsPerformance(props) {
}
}
async function fetchLogInfo() {
try {
const res = await API.get('/api/performance/logs');
if (res.data.success) {
setLogInfo(res.data.data);
}
} catch (error) {
console.error('Failed to fetch log info:', error);
}
}
async function cleanupLogFiles() {
if (logCleanupValue == null || isNaN(logCleanupValue) || logCleanupValue < 1) {
showError(t('请输入有效的数值'));
return;
}
setLogCleanupLoading(true);
try {
const res = await API.delete(
`/api/performance/logs?mode=${logCleanupMode}&value=${logCleanupValue}`,
);
if (res.data.success) {
const { deleted_count, freed_bytes } = res.data.data;
showSuccess(
t('已清理 {{count}} 个日志文件,释放 {{size}}', {
count: deleted_count,
size: formatBytes(freed_bytes),
}),
);
} else {
showError(res.data.message || t('清理失败'));
}
fetchLogInfo();
} catch (error) {
showError(t('清理失败'));
} finally {
setLogCleanupLoading(false);
}
}
useEffect(() => {
const currentInputs = {};
for (let key in props.options) {
@@ -187,6 +234,7 @@ export default function SettingsPerformance(props) {
refForm.current.setValues({ ...inputs, ...currentInputs });
}
fetchStats();
fetchLogInfo();
}, [props.options]);
const diskCacheUsagePercent =
@@ -351,6 +399,112 @@ export default function SettingsPerformance(props) {
</Form>
</Spin>
{/* 服务器日志管理 */}
<Form.Section text={t('服务器日志管理')}>
<Banner
type='info'
description={t(
'管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。',
)}
style={{ marginBottom: 16 }}
/>
{logInfo === null ? null : logInfo.enabled ? (
<>
<Descriptions
data={[
{ key: t('日志目录'), value: logInfo.log_dir },
{
key: t('日志文件数'),
value: logInfo.file_count,
},
{
key: t('日志总大小'),
value: formatBytes(logInfo.total_size),
},
...(logInfo.oldest_time && logInfo.newest_time
? [
{
key: t('日志时间范围'),
value: `${new Date(logInfo.oldest_time).toLocaleDateString()} ~ ${new Date(logInfo.newest_time).toLocaleDateString()}`,
},
]
: []),
]}
style={{ marginBottom: 16 }}
/>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<div style={{ marginBottom: 12 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
{t('清理方式')}
</Text>
<RadioGroup
value={logCleanupMode}
onChange={(e) => setLogCleanupMode(e.target.value)}
>
<Radio value='by_count'>{t('保留最近N个文件')}</Radio>
<Radio value='by_days'>{t('保留最近N天')}</Radio>
</RadioGroup>
</div>
</Col>
<Col xs={24} sm={12} md={8}>
<div style={{ marginBottom: 12 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
{logCleanupMode === 'by_count'
? t('保留文件数')
: t('保留天数')}
</Text>
<InputNumber
value={logCleanupValue}
min={1}
max={logCleanupMode === 'by_count' ? 1000 : 3650}
onChange={(value) => setLogCleanupValue(value)}
style={{ width: '100%' }}
/>
</div>
</Col>
<Col xs={24} sm={12} md={8}>
<div style={{ marginBottom: 12 }}>
<Text
strong
style={{
display: 'block',
marginBottom: 8,
visibility: 'hidden',
}}
>
&nbsp;
</Text>
<Popconfirm
title={t('确认清理日志文件?')}
content={
logCleanupMode === 'by_count'
? t(
'将只保留最近 {{value}} 个日志文件,其余将被删除。',
{ value: logCleanupValue },
)
: t('将删除 {{value}} 天前的日志文件。', {
value: logCleanupValue,
})
}
onConfirm={cleanupLogFiles}
>
<Button type='danger' loading={logCleanupLoading}>
{t('清理日志文件')}
</Button>
</Popconfirm>
</div>
</Col>
</Row>
</>
) : (
<Banner
type='warning'
description={t('服务器日志功能未启用(未配置日志目录)')}
/>
)}
</Form.Section>
{/* 性能统计 */}
<Spin spinning={statsLoading}>
<Form.Section text={t('性能监控')}>