Compare commits

...

56 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
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
89 changed files with 3323 additions and 1807 deletions
+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 \
+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
}
}
+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
+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{
+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=
+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
+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 {
+3 -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信息
+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
}
+3 -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
@@ -226,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() {
+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;
+27 -3
View File
@@ -935,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",
@@ -1105,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",
@@ -1507,6 +1507,8 @@
"收益统计": "Income statistics",
"收起": "Collapse",
"收起侧边栏": "Collapse sidebar",
"向左展开": "Expand left",
"向右展开": "Expand right",
"收起内容": "Collapse content",
"放大": "Upscalers",
"放大编辑": "Expand editor",
@@ -2676,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",
@@ -3220,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",
@@ -3337,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"
}
}
+20 -3
View File
@@ -928,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",
@@ -1097,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",
@@ -1495,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",
@@ -3164,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",
@@ -3300,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"
}
}
+20 -3
View File
@@ -919,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フィルタリストを設定している場合は、これらのアドレスをカバーしていることを確認してください。",
"域名白名单": "ドメインホワイトリスト",
"域名黑名单": "ドメインブラックリスト",
"基本信息": "基本情報",
@@ -1088,7 +1088,7 @@
"密钥预览": "APIキーのプレビュー",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "公式チャネルの場合、new-apiにはベースURLが組み込まれているため、サードパーティのプロキシサイトやAzureの専用のエンドポイントでない限り、入力する必要はありません。",
"对免费模型启用预消耗": "Enable pre-consumption for free models",
"对域名启用 IP 过滤(实验性": "ドメインのIPフィルタリングを有効にする(実験的",
"对域名启用 IP 过滤(推荐开启": "ドメインのIPフィルタリングを有効にする(推奨",
"对外运营模式": "公開運用モード",
"对象清理规则": "オブジェクトプルーニングルール",
"导入": "インポート",
@@ -1478,6 +1478,8 @@
"收益统计": "収益統計",
"收起": "折りたたみ",
"收起侧边栏": "サイドバー折りたたみ",
"向左展开": "左に展開",
"向右展开": "右に展開",
"收起内容": "コンテンツ折りたたみ",
"放大": "アップスケール",
"放大编辑": "エディタで開く",
@@ -3145,6 +3147,12 @@
"高级设置": "詳細設定",
"高级选项": "高度なオプション",
"高级配置": "Advanced Configuration",
"核心配置": "基本設定",
"创建渠道所需的基本信息": "チャネル作成に必要な基本情報",
"请求配置": "リクエスト設定",
"渠道行为": "チャネル動作",
"额外设置": "追加設定",
"上游模型管理": "上流モデル管理",
"黑名单": "ブラックリスト",
"默认": "デフォルト",
"默认 API 版本": "デフォルトAPIバージョン",
@@ -3281,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",
"复制密钥": "キーをコピー",
"复制连接信息": "接続情報をコピー",
"检测到剪贴板中的连接信息": "クリップボードに接続情報が検出されました",
"自动填入": "自動入力",
"忽略": "無視",
"从剪贴板粘贴配置": "クリップボードから貼り付け",
"剪贴板中未检测到连接信息": "クリップボードに接続情報が見つかりません",
"连接信息已填入": "接続情報を入力しました",
"无法读取剪贴板": "クリップボードを読み取れません"
}
}
+20 -3
View File
@@ -934,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, убедитесь, что он покрывает эти адреса, иначе доступ может быть нарушен.",
"域名白名单": "Белый список доменов",
"域名黑名单": "Чёрный список доменов",
"基本信息": "Основная информация",
@@ -1103,7 +1103,7 @@
"密钥预览": "Предпросмотр ключа",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "Для официальных каналов new-api уже имеет встроенные адреса, если это не сторонние прокси-сайты или специальные адреса доступа Azure, заполнять не нужно",
"对免费模型启用预消耗": "Включить предварительное списание для бесплатных моделей",
"对域名启用 IP 过滤(实验性": "Включить IP-фильтрацию для доменов (экспериментально)",
"对域名启用 IP 过滤(推荐开启": "Включить IP-фильтрацию для доменов (рекомендуется)",
"对外运营模式": "Режим внешней эксплуатации",
"对象清理规则": "Правила очистки объектов",
"导入": "Импорт",
@@ -1507,6 +1507,8 @@
"收益统计": "Статистика доходов",
"收起": "Свернуть",
"收起侧边栏": "Свернуть боковую панель",
"向左展开": "Развернуть влево",
"向右展开": "Развернуть вправо",
"收起内容": "Свернуть содержимое",
"放大": "Увеличить",
"放大编辑": "Увеличить и редактировать",
@@ -3178,6 +3180,12 @@
"高级设置": "Расширенные настройки",
"高级选项": "Расширенные параметры",
"高级配置": "Advanced Configuration",
"核心配置": "Основные настройки",
"创建渠道所需的基本信息": "Основная информация для создания канала",
"请求配置": "Настройки запросов",
"渠道行为": "Поведение канала",
"额外设置": "Дополнительные настройки",
"上游模型管理": "Управление моделями апстрима",
"黑名单": "Черный список",
"默认": "По умолчанию",
"默认 API 版本": "Версия API по умолчанию",
@@ -3314,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",
"复制密钥": "Копировать ключ",
"复制连接信息": "Копировать данные подключения",
"检测到剪贴板中的连接信息": "В буфере обмена обнаружены данные подключения",
"自动填入": "Заполнить",
"忽略": "Игнорировать",
"从剪贴板粘贴配置": "Вставить конфигурацию",
"剪贴板中未检测到连接信息": "Данные подключения не найдены в буфере обмена",
"连接信息已填入": "Данные подключения применены",
"无法读取剪贴板": "Не удалось прочитать буфер обмена"
}
}
+20 -3
View File
@@ -920,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",
@@ -1089,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",
@@ -1479,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",
@@ -3716,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",
@@ -3851,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"
}
}
+12 -3
View File
@@ -735,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 过滤列表,请确保覆盖这些地址,否则可能导致访问失败。",
"域名白名单": "域名白名单",
"域名黑名单": "域名黑名单",
"基本信息": "基本信息",
@@ -870,7 +870,7 @@
"密钥预览": "密钥预览",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写",
"对免费模型启用预消耗": "对免费模型启用预消耗",
"对域名启用 IP 过滤(实验性": "对域名启用 IP 过滤(实验性",
"对域名启用 IP 过滤(推荐开启": "对域名启用 IP 过滤(推荐开启",
"对外运营模式": "对外运营模式",
"导入": "导入",
"导入的配置将覆盖当前设置,是否继续?": "导入的配置将覆盖当前设置,是否继续?",
@@ -2956,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",
"复制密钥": "复制密钥",
"复制连接信息": "复制连接信息",
"检测到剪贴板中的连接信息": "检测到剪贴板中的连接信息",
"自动填入": "自动填入",
"忽略": "忽略",
"从剪贴板粘贴配置": "从剪贴板粘贴配置",
"剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息",
"连接信息已填入": "连接信息已填入",
"无法读取剪贴板": "无法读取剪贴板"
}
}
+20 -3
View File
@@ -737,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 過濾列表,請確保覆蓋這些位址,否則可能導致訪問失敗。",
"域名白名单": "域名白名單",
"域名黑名单": "域名黑名單",
"基本信息": "基本資訊",
@@ -873,7 +873,7 @@
"密钥预览": "密鑰預覽",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "對於官方管道,new-api已經內置位址,除非是第三方代理站點或者Azure的特殊接入位址,否則不需要填寫",
"对免费模型启用预消耗": "對免費模型啟用預消耗",
"对域名启用 IP 过滤(实验性": "對域名啟用 IP 過濾(實驗性",
"对域名启用 IP 过滤(推荐开启": "對域名啟用 IP 過濾(建議開啟",
"对外运营模式": "對外運營模式",
"导入": "導入",
"导入的配置将覆盖当前设置,是否继续?": "導入的設定將覆蓋當前設定,是否繼續?",
@@ -1190,6 +1190,8 @@
"收益统计": "收益統計",
"收起": "收起",
"收起侧边栏": "收起側邊欄",
"向左展开": "向左展開",
"向右展开": "向右展開",
"收起内容": "收起內容",
"放大": "放大",
"放大编辑": "放大編輯",
@@ -2653,6 +2655,12 @@
"验证配置错误": "驗證設定錯誤",
"高级设置": "進階設定",
"高级配置": "進階設定",
"核心配置": "核心設定",
"创建渠道所需的基本信息": "建立頻道所需的基本資訊",
"请求配置": "請求設定",
"渠道行为": "頻道行為",
"额外设置": "額外設定",
"上游模型管理": "上游模型管理",
"黑名单": "黑名單",
"默认": "預設",
"默认 API 版本": "預設 API 版本",
@@ -2965,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>
@@ -71,7 +71,7 @@ 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);