Compare commits
10
Commits
2b0efd8484
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e468b73915 | ||
|
|
ba2e9287bb | ||
|
|
cae3676ec6 | ||
|
|
8f6961c675 | ||
|
|
8c25eee71b | ||
|
|
a073f74b38 | ||
|
|
2d8e50bf36 | ||
|
|
f116414284 | ||
|
|
137d1171f2 | ||
|
|
4add708ebe |
@@ -0,0 +1,178 @@
|
||||
<!--
|
||||
Agent-only issue body. Humans use GitHub issue forms.
|
||||
|
||||
Keep every heading. If a section does not apply, write why; do not delete it.
|
||||
Match the user's language in the filled answers. Quote what the user said; do not invent facts.
|
||||
|
||||
If a field below is missing from the conversation, ask the user that question
|
||||
and wait. Ask for the facts themselves. Do not tell the user to confirm a
|
||||
template, tick checkboxes, or acknowledge the guidelines.
|
||||
|
||||
Before filing, refuse out-of-scope requests listed below. Then search the docs,
|
||||
DeepWiki, README, and the code. If this is a usage, configuration, or
|
||||
integration question, answer the user from that material and do not file.
|
||||
-->
|
||||
|
||||
## Agent
|
||||
|
||||
- Tool:
|
||||
- Tool version:
|
||||
- Model (full id):
|
||||
- Host (CLI / IDE / GitHub coding agent / other):
|
||||
- Date (UTC):
|
||||
|
||||
## User request
|
||||
|
||||
(verbatim or close paraphrase)
|
||||
|
||||
## Out of scope — refuse
|
||||
|
||||
If the request matches any item below, tell the user this repository does not
|
||||
accept it, point them to the right place when there is one, and **do not file**.
|
||||
|
||||
- Coding Plan
|
||||
- Reverse-engineered channels
|
||||
- Third-party API wrappers
|
||||
- Compatibility issues from exposing a Codex endpoint as a general-purpose API through a reverse proxy
|
||||
- Codex API-specific protocol or behavior treated as standard OpenAI API behavior (confirm with the channel or API provider)
|
||||
- Pass-through mode forwarding (pass-through forwards as-is; verify upstream yourself)
|
||||
- Relay reports that only paste an upstream error, with no direct-upstream vs new-api comparison
|
||||
- Third-party hosting sites, relay services, or API services (contact their operator)
|
||||
- Usage, configuration, or integration questions (answer from docs and code instead)
|
||||
|
||||
- Matched: yes/no
|
||||
- If yes, what was told to the user (stop here; do not file):
|
||||
|
||||
## Kind
|
||||
|
||||
- [ ] Bug
|
||||
- [ ] Feature
|
||||
- [ ] Investigation
|
||||
- [ ] Other:
|
||||
|
||||
## Usage / configuration / integration check
|
||||
|
||||
Search these yourself before filing. Do not send the user to "read the docs first". If this is usage, configuration, or integration: answer the user and do not file.
|
||||
|
||||
- https://docs.newapi.ai/ — what was searched, conclusion:
|
||||
- https://deepwiki.com/QuantumNous/new-api — what was searched, conclusion:
|
||||
- README / repo docs:
|
||||
- Relevant code paths and conclusion:
|
||||
- Can the current version already do this? (required for feature requests):
|
||||
- Verdict: product bug or new feature / usage question (stop here):
|
||||
|
||||
## Environment
|
||||
|
||||
- new-api version / commit / image tag (not `latest` / `unknown`):
|
||||
- Deploy source (repo release / official image / main source / other):
|
||||
- Database (sqlite / mysql / postgres):
|
||||
|
||||
## Problem facts
|
||||
|
||||
Ask the user for every item and write it down:
|
||||
|
||||
- Actual behavior:
|
||||
- Impact:
|
||||
- Frequency:
|
||||
- Evidence that the problem is in new-api rather than the client or upstream:
|
||||
|
||||
## Type-specific details
|
||||
|
||||
Fill every applicable type. Write "not applicable" for the rest. Ask the user for missing items; do not invent them.
|
||||
|
||||
### Relay / API
|
||||
|
||||
- Request endpoint and method:
|
||||
- Channel type:
|
||||
- Model:
|
||||
- Conversion format:
|
||||
- Pass-through enabled:
|
||||
- Evidence of upstream native support:
|
||||
- Equivalent redacted request sent directly upstream: status, body, server logs:
|
||||
- Same request through new-api: status, body, server logs:
|
||||
|
||||
### Billing
|
||||
|
||||
- Request endpoint and model:
|
||||
- Response `usage`:
|
||||
- Relevant ratio or pricing configuration:
|
||||
- Consumption log:
|
||||
- Expected charge and calculation basis:
|
||||
|
||||
### Frontend
|
||||
|
||||
- Page path:
|
||||
- Browser and version:
|
||||
- Active theme:
|
||||
- Relevant browser Console / Network errors:
|
||||
|
||||
### Deployment / upgrade
|
||||
|
||||
- Deployment method:
|
||||
- OS and architecture:
|
||||
- Database type:
|
||||
- Versions before and after the upgrade:
|
||||
- Startup or migration logs:
|
||||
|
||||
## Reproduction and expected result
|
||||
|
||||
- Steps to reproduce:
|
||||
- Expected result:
|
||||
- Related screenshots (optional):
|
||||
|
||||
## Feature (feature requests only)
|
||||
|
||||
- Feature description:
|
||||
- Use case:
|
||||
|
||||
## Duplicate check
|
||||
|
||||
- Search queries (issues, PRs, discussions):
|
||||
- Closest existing threads:
|
||||
- Why this is not a duplicate:
|
||||
|
||||
## Research
|
||||
|
||||
Open the docs and code. Do not write "already checked" without sources.
|
||||
|
||||
### Docs
|
||||
|
||||
- https://docs.newapi.ai/ :
|
||||
- https://deepwiki.com/QuantumNous/new-api :
|
||||
- README / other repo docs:
|
||||
- Conclusions:
|
||||
|
||||
### Code
|
||||
|
||||
- Path — what it does, and how it relates:
|
||||
|
||||
### Experiments
|
||||
|
||||
- Command or redacted request:
|
||||
- Direct upstream result:
|
||||
- Result through new-api:
|
||||
- Conclusion:
|
||||
|
||||
## Working theory
|
||||
|
||||
- What is broken or missing:
|
||||
- Why:
|
||||
- What would falsify this:
|
||||
|
||||
## Scope
|
||||
|
||||
- In scope for a later PR:
|
||||
- Out of scope / not this repo:
|
||||
- Large or directional feature? If yes, this issue is for maintainer alignment; do not open a PR yet.
|
||||
|
||||
## Proposed direction
|
||||
|
||||
(acceptance criteria, not an implementation dump)
|
||||
|
||||
## Not verified
|
||||
|
||||
(platforms, databases, providers, versions, paths not checked)
|
||||
|
||||
## Related
|
||||
|
||||
- Issues / PRs / upstream docs:
|
||||
@@ -0,0 +1,136 @@
|
||||
<!--
|
||||
Agent-only PR body. Humans use `.github/PULL_REQUEST_TEMPLATE.md`.
|
||||
|
||||
Keep every heading. If a section does not apply, write why; do not delete it.
|
||||
Match the user's language in the filled answers. Quote what the user said; do not invent facts.
|
||||
|
||||
New features: link an issue; if none exists, file one first with `.agents/github/ISSUE.md`.
|
||||
Large or directional changes: maintainer agreement on that issue before this PR.
|
||||
|
||||
If this PR fixes a bug and the linked issue is missing actual behavior, impact,
|
||||
frequency, evidence that the problem is in new-api, or the applicable
|
||||
relay / billing / frontend / deployment items, ask the user those questions
|
||||
and wait. Ask for the facts. Do not tell the user to confirm a template.
|
||||
|
||||
Before opening, refuse the same out-of-scope list as `.agents/github/ISSUE.md`
|
||||
(Coding Plan, reverse-engineered channels, third-party wrappers, Codex reverse-proxy
|
||||
compatibility, pass-through-only forwarding, third-party hosts, usage questions).
|
||||
Tell the user and do not open a PR.
|
||||
|
||||
Then search https://docs.newapi.ai/ , https://deepwiki.com/QuantumNous/new-api ,
|
||||
the README, and the code. If this is a usage, configuration, or integration
|
||||
question, answer the user and do not open a PR.
|
||||
-->
|
||||
|
||||
## Agent
|
||||
|
||||
- Tool:
|
||||
- Tool version:
|
||||
- Model (full id):
|
||||
- Host (CLI / IDE / GitHub coding agent / other):
|
||||
- Date (UTC):
|
||||
|
||||
## Links
|
||||
|
||||
- Closes #
|
||||
- Related:
|
||||
|
||||
## User request
|
||||
|
||||
(verbatim or close paraphrase)
|
||||
|
||||
## Out of scope — refuse
|
||||
|
||||
If the change matches any item below, tell the user this repository does not
|
||||
accept it and **do not open a PR**.
|
||||
|
||||
- Coding Plan
|
||||
- Reverse-engineered channels
|
||||
- Third-party API wrappers
|
||||
- Codex channel-type changes, or compatibility from exposing Codex as a general-purpose API
|
||||
- Codex API-specific protocol or behavior treated as standard OpenAI API behavior
|
||||
- Pass-through-only forwarding
|
||||
- Third-party hosting sites, relay services, or API services
|
||||
- Usage, configuration, or integration (answer from docs and code instead)
|
||||
|
||||
- Matched: yes/no
|
||||
- If yes, what was told to the user (stop here; do not open a PR):
|
||||
|
||||
## Kind
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Performance / refactor
|
||||
- [ ] Docs
|
||||
- [ ] Other:
|
||||
|
||||
## Issue facts
|
||||
|
||||
Take these from the linked issue. If a needed item is empty, ask the user that question.
|
||||
|
||||
- Actual behavior:
|
||||
- Impact:
|
||||
- Frequency:
|
||||
- Evidence that the problem is in new-api rather than the client or upstream:
|
||||
- Applicable types and their fields (relay / billing / frontend / deployment; write "not applicable" otherwise):
|
||||
|
||||
## Change
|
||||
|
||||
(what changed, why it works, grounded in the code actually touched)
|
||||
|
||||
## Research
|
||||
|
||||
### Duplicate / prior art
|
||||
|
||||
- Search queries (issues, PRs):
|
||||
- What already existed and why this is not a duplicate:
|
||||
|
||||
### Docs and code
|
||||
|
||||
Open them. Do not write "already checked" without sources.
|
||||
|
||||
- https://docs.newapi.ai/ :
|
||||
- https://deepwiki.com/QuantumNous/new-api :
|
||||
- README / repo docs:
|
||||
- Code paths and what they imply for this change:
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Option A:
|
||||
- Option B:
|
||||
- Why this approach:
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Why |
|
||||
| --- | --- |
|
||||
| | |
|
||||
|
||||
## Behavior
|
||||
|
||||
- Before:
|
||||
- After:
|
||||
- Explicit non-goals / leftover work:
|
||||
|
||||
## Verification
|
||||
|
||||
Only what was actually run.
|
||||
|
||||
- Commands and results:
|
||||
- Manual steps and observed result:
|
||||
- UI: screenshot or recording (or why none):
|
||||
- Tests added or updated, or why none:
|
||||
- Databases / providers / platforms exercised:
|
||||
- Not verified:
|
||||
|
||||
## Risks
|
||||
|
||||
- Failure modes:
|
||||
- Billing / quota / auth impact:
|
||||
- Follow-ups:
|
||||
|
||||
## Scope check
|
||||
|
||||
- Single focused change: yes/no (if no, why):
|
||||
- Secrets included: no
|
||||
- Out of scope (Coding Plan / reverse-engineered channel / third-party wrapper / Codex): no
|
||||
@@ -35,6 +35,8 @@
|
||||
# SQL_MAX_LIFETIME=60
|
||||
# 慢查询日志阈值(毫秒),0 表示关闭慢查询日志,超出 0-3600000 范围回退默认值 200
|
||||
# SQL_SLOW_THRESHOLD_MS=200
|
||||
# 跳过用户额度列 64 位 schema 检查(仅在已确认数据库列可容纳 64 位时启用)
|
||||
# SKIP_64BIT_QUOTA_SCHEMA_CHECK=true
|
||||
|
||||
|
||||
# 缓存相关配置
|
||||
|
||||
@@ -6,6 +6,9 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
<!--
|
||||
If you are an AI coding agent: do not fill this GitHub form. Create the issue with the filled body from `.agents/github/ISSUE.md`.
|
||||
-->
|
||||
## 提交前必读(请勿删除本节)
|
||||
|
||||
- 文档:https://docs.newapi.ai/
|
||||
|
||||
@@ -6,6 +6,9 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
<!--
|
||||
If you are an AI coding agent: do not fill this GitHub form. Create the issue with the filled body from `.agents/github/ISSUE.md`.
|
||||
-->
|
||||
## Read This First (Do Not Remove This Section)
|
||||
|
||||
- Docs: https://docs.newapi.ai/
|
||||
|
||||
@@ -6,6 +6,9 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
<!--
|
||||
If you are an AI coding agent: do not fill this GitHub form. Create the issue with the filled body from `.agents/github/ISSUE.md`.
|
||||
-->
|
||||
## 提交前必读(请勿删除本节)
|
||||
|
||||
- 文档:https://docs.newapi.ai/
|
||||
|
||||
@@ -6,6 +6,9 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
<!--
|
||||
If you are an AI coding agent: do not fill this GitHub form. Create the issue with the filled body from `.agents/github/ISSUE.md`.
|
||||
-->
|
||||
## Read This First (Do Not Remove This Section)
|
||||
|
||||
- Docs: https://docs.newapi.ai/
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
<!--
|
||||
If you are an AI coding agent (Claude Code, Codex, Cursor, Copilot, OpenCode, Paseo, Grok, or similar): do not fill this human template. Read `.agents/github/PR.md` and use the filled file as the entire PR body.
|
||||
-->
|
||||
# ⚠️ 提交说明 / PR Notice
|
||||
|
||||
English template: `.github/PULL_REQUEST_TEMPLATE/en.md`
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> - 请提供**人工撰写**的简洁摘要,避免直接粘贴未经整理的 AI 输出。
|
||||
> - 描述可用 AI 辅助。提交前请审阅全文,并**声明对其负责**,避免未经核对的直接粘贴。
|
||||
> - 请按本模板填写后再提交。
|
||||
|
||||
## 📝 变更描述 / Description
|
||||
(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)
|
||||
## 🔗 关联任务 / Related Issue
|
||||
- 新功能请填写下方 Issue 编号;若还没有对应 Issue,请先自行创建。功能讨论请放在 Issue 中进行。
|
||||
- 改动较大或方向性变更,请先在关联 Issue 中与维护者达成一致,再提交 PR。
|
||||
- Bug 修复请关联对应 Issue。设计取舍、理解偏差或预期不一致,更适合作为讨论或功能请求。
|
||||
|
||||
- Closes #
|
||||
|
||||
## 🚀 变更类型 / Type of change
|
||||
- [ ] 🐛 Bug 修复 (Bug fix) - *请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug*
|
||||
- [ ] ✨ 新功能 (New feature) - *重大特性建议先通过 Issue 沟通*
|
||||
- [ ] 🐛 Bug 修复 (Bug fix)
|
||||
- [ ] ✨ 新功能 (New feature)
|
||||
- [ ] ⚡ 性能优化 / 重构 (Refactor)
|
||||
- [ ] 📝 文档更新 (Documentation)
|
||||
|
||||
## 🔗 关联任务 / Related Issue
|
||||
- Closes # (如有)
|
||||
|
||||
## ✅ 提交前检查项 / Checklist
|
||||
- [ ] **人工确认:** 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
|
||||
- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。
|
||||
- [ ] **Bug fix 说明:** 若此 PR 标记为 `Bug fix`,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
|
||||
- [ ] **变更理解:** 我已理解这些更改的工作原理及可能影响。
|
||||
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
|
||||
- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
|
||||
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
|
||||
## 📝 变更描述 / Description
|
||||
(简述做了什么、为什么生效。如果难以简述,建议先拆分范围,或在 Issue 中与维护者对齐。)
|
||||
|
||||
## 📸 运行证明 / Proof of Work
|
||||
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
|
||||
(请写明如何验证:实际步骤与观察结果。UI 变更请附截图或录屏;Bug 修复请说明复现过程与修复后结果。)
|
||||
|
||||
## ✅ 提交前检查项 / Checklist
|
||||
- [ ] **人工确认:** 无论描述是否由 AI 生成,我已审阅全部内容,并声明对其准确性与完整性负责。
|
||||
- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。
|
||||
- [ ] **新功能关联 Issue:** 若此 PR 标记为 `New feature`,我已关联对应 Issue;若尚无 Issue,我已先自行创建。
|
||||
- [ ] **事前沟通:** 若改动较大或涉及方向性变更,已在关联 Issue 中与维护者沟通并达成一致。
|
||||
- [ ] **功能范围:** 本 PR 不是 Coding Plan、逆向渠道、第三方封装接口,也不是对 Codex 渠道类型的改动。
|
||||
- [ ] **范围聚焦:** 本 PR 为一项聚焦改动,未包含无关代码。
|
||||
- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核。
|
||||
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<!--
|
||||
If you are an AI coding agent (Claude Code, Codex, Cursor, Copilot, OpenCode, Paseo, Grok, or similar): do not fill this human template. Read `.agents/github/PR.md` and use the filled file as the entire PR body.
|
||||
-->
|
||||
# PR Notice
|
||||
|
||||
Chinese template: `.github/PULL_REQUEST_TEMPLATE.md`
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> - AI-assisted descriptions are welcome. Please review the full text before submitting and **take responsibility** for it. Avoid pasting unreviewed content.
|
||||
> - Please complete this template before submitting.
|
||||
|
||||
## Related Issue
|
||||
- For new features, please fill in the Issue number below. If none exists yet, please create one first. Please discuss the feature in the Issue rather than using the PR in its place.
|
||||
- For large or directional changes, please reach agreement with maintainers in the linked Issue before opening a PR.
|
||||
- Bug fixes should link a corresponding Issue. Design trade-offs, misunderstandings, or mismatched expectations are a better fit for a discussion or feature request.
|
||||
|
||||
- Closes #
|
||||
|
||||
## Type of change
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Performance / Refactor
|
||||
- [ ] Documentation
|
||||
|
||||
## Description
|
||||
(Briefly describe what changed and why it works. If that is hard to summarize, consider splitting the scope or aligning with maintainers in an Issue first.)
|
||||
|
||||
## Proof of Work
|
||||
(Please describe how this was verified: the steps run and what was observed. For UI changes, please include a screenshot or recording. For bug fixes, please describe the reproduction and the result after the fix.)
|
||||
|
||||
## Checklist
|
||||
- [ ] **Human review:** Whether or not the description was AI-generated, I have reviewed the full content and take responsibility for its accuracy and completeness.
|
||||
- [ ] **Not a duplicate:** I have searched existing [Issues](https://github.com/QuantumNous/new-api/issues) and [PRs](https://github.com/QuantumNous/new-api/pulls) and confirmed this is not a duplicate.
|
||||
- [ ] **Feature issue:** If this PR is a New feature, I have linked a corresponding Issue; if none existed, I created one first.
|
||||
- [ ] **Prior discussion:** If this is a large or directional change, I have discussed it with maintainers in the linked Issue and reached agreement.
|
||||
- [ ] **Scope:** This PR is not a Coding Plan, reverse-engineered channel, third-party API wrapper, or a change to the Codex channel type.
|
||||
- [ ] **Focused change:** This PR is a single focused change and does not include unrelated code.
|
||||
- [ ] **Local verification:** I have run tests or manually verified locally so maintainers can re-check from this evidence.
|
||||
- [ ] **Security:** This change does not include secrets and follows the project's coding guidelines.
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: '1.3.14'
|
||||
bun-version: '1.4.0'
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: '1.4.0'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
name: PR Check
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
pr-quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1
|
||||
with:
|
||||
max-failures: 4
|
||||
require-description: true
|
||||
|
||||
# require-linked-issue: false
|
||||
blocked-terms: |
|
||||
🤖 Generated with Claude Code
|
||||
|
||||
require-pr-template: true
|
||||
strict-pr-template-sections: "✅ 提交前检查项 / Checklist"
|
||||
|
||||
detect-spam-usernames: true
|
||||
min-account-age: 30
|
||||
|
||||
failure-add-pr-labels: "pr-check-failed"
|
||||
failure-pr-message: "感谢您的提交。由于该 PR 未遵循我们的贡献模板,且被识别为缺乏人工参与的纯 AI 生成内容 (AI Slop),我们将先予以关闭。我们更欢迎经过人工审核、验证并带有个人思考的贡献。如果您认为这其中存在误解,请回复告知。/ Thank you for your submission. This PR has been closed because it does not follow our contribution template and has been identified as purely AI-generated content (AI Slop) without meaningful human involvement. We prioritize contributions that are human-verified and reflect individual effort. If you believe this is a mistake, please let us know by replying to this comment."
|
||||
close-pr: true
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: '1.4.0'
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: '1.4.0'
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: '1.4.0'
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
|
||||
@@ -107,7 +107,7 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag
|
||||
- Every user-controlled quantity that becomes a billing multiplier (image `n`, video `seconds`/`duration`, resolution/quality ratios, batch counts) MUST be bounded before it reaches quota calculation. Reject out-of-range values at request validation with a 400. Existing bounds: `dto.MaxImageN` for image generation count, `relaycommon.MaxTaskDurationSeconds` for task video duration, `maxTokensLimit` (`relay/helper/valid_request.go`) for `max_tokens`-family fields on every relay format (OpenAI, Claude, Gemini, Responses). Reuse these constants instead of introducing new ad hoc limits for the same concepts. When adding a new relay format or request DTO, bound its max-tokens and count fields in its validator from day one.
|
||||
- Watch for validation bypass paths: passthrough fields (e.g. `Extra["parameters"]`), task `metadata` maps, and multipart form fields can carry the same quantities around the standard DTO validation. Any adaptor that reads a multiplier from such a path must enforce the same bound (or clamp) locally.
|
||||
- Durations parsed from media metadata are user/upstream-controlled too: audio file headers (transcription token counting, TTS response duration) and upstream deduction numbers (e.g. Kling `FinalUnitDeduction`) can claim absurd values. Convert them with saturation before they become token counts.
|
||||
- Never convert a computed quota or token count to `int` with a bare cast like `int(float64(quota) * ratio)`, `int(math.Round(...))` on unbounded input, or `int(decimal.IntPart())`. All quota rounding/conversion is centralized in `common/quota_math.go`; use those helpers: `common.QuotaFromFloat` (truncating) for float products, `common.QuotaRound` (half-away-from-zero) where rounding is intended, and `common.QuotaFromDecimal` for decimal products. `billingexpr.QuotaRound` delegates to `common.QuotaRound`. Do not reintroduce local conversion helpers or bare casts. Saturation bounds are int32 because quota columns (user/token/log) are 32-bit integers in the database, and every clamp/NaN fallback is logged via `common.SysError` since a single request should never approach those bounds.
|
||||
- Never convert a computed quota or token count to `int` with a bare cast like `int(float64(quota) * ratio)`, `int(math.Round(...))` on unbounded input, or `int(decimal.IntPart())`. All quota rounding/conversion is centralized in `common/quota_math.go`; use those helpers: `common.QuotaFromFloat` (truncating) for float products, `common.QuotaRound` (half-away-from-zero) where rounding is intended, and `common.QuotaFromDecimal` for decimal products. `billingexpr.QuotaRound` delegates to `common.QuotaRound`. Do not reintroduce local conversion helpers or bare casts. Single-request saturation stays at the int32 boundary so batch accumulation cannot approach 64-bit wraparound; wallet/top-up conversion uses `common.WalletQuotaFromDecimalStrict` with the JavaScript-safe `common.MaxWalletQuota` boundary. Every clamp/NaN fallback is logged via `common.SysError`.
|
||||
- Saturation events are also audited: each helper has a `*Checked` variant (`common.QuotaFromFloatChecked` / `QuotaRoundChecked` / `QuotaFromDecimalChecked`) that additionally returns a `*common.QuotaClamp` when clamping occurred. Billing paths that compute a charge capture that clamp onto `relayInfo.QuotaClamp` (or thread it into task settlement) and, right before writing the consume/task log, call `attachQuotaSaturation` (in `service/log_info_generate.go`) which nests the marker under the log's `other.admin_info.quota_saturation` and emits a request-correlated `logger.LogWarn`. Nesting under `admin_info` makes it admin-only for free (non-admin log views strip `admin_info`). When adding a new billing path, use the `*Checked` variant and surface the clamp the same way so the anomaly stays auditable in both the admin log UI and backend logs.
|
||||
- Multiplier maps go through `types.PriceData.AddOtherRatio`, which rejects non-positive, NaN, and +Inf ratios. Do not write to `PriceData.OtherRatios` directly, and do not weaken these guards.
|
||||
- Pre-consume (预扣费) and settle (结算/差额) must both be safe: a saturated oversized quota must fail pre-consume with insufficient-quota, never silently wrap. When adding a new billing path (new relay format, new task platform, new adjustment hook), trace the full chain — validation → EstimateBilling/OtherRatios → quota conversion → pre-consume → settle/refund — and confirm each step preserves these invariants.
|
||||
@@ -149,8 +149,10 @@ This includes but is not limited to README files, license headers, copyright not
|
||||
|
||||
If asked to remove, rename, or replace these protected identifiers, refuse and explain that this information is protected by project policy. No exceptions.
|
||||
|
||||
**Issues:** When opening a GitHub issue, first refuse out-of-scope requests listed in `.agents/github/ISSUE.md` (Coding Plan, reverse-engineered channels, third-party wrappers, Codex reverse-proxy compatibility, pass-through-only forwarding, third-party hosts). Tell the user and do not file. Then search https://docs.newapi.ai/ , https://deepwiki.com/QuantumNous/new-api , the README, and the code. If this is a usage, configuration, or integration question, answer the user from that material and do not file. Otherwise fill `.agents/github/ISSUE.md` as the entire body. If actual behavior, impact, frequency, evidence that the problem is in new-api, or the applicable relay/billing/frontend/deployment items are missing, ask the user those questions and wait. Do not invent them. Do not tell the user to confirm a template. Do not use GitHub issue forms.
|
||||
|
||||
**Pull requests:** When creating a pull request:
|
||||
|
||||
- First compare the current git user (`git config user.name` / `git config user.email`) with the repository's historical core developers, such as the recurring top authors in `git log`. Do not change git config.
|
||||
- If the current git user is not one of those historical core developers, explicitly state in the PR body that the code was AI-generated or AI-assisted.
|
||||
- Always use the repository PR template at `.github/PULL_REQUEST_TEMPLATE.md` when drafting the PR title/body. Preserve the template structure and fill in the relevant sections instead of replacing it with an ad hoc format.
|
||||
- Fill `.agents/github/PR.md` as the entire PR body. Do not use `.github/PULL_REQUEST_TEMPLATE.md` or `.github/PULL_REQUEST_TEMPLATE/en.md`.
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
|
||||
FROM oven/bun:1.4.0@sha256:5ff609364c049b54eb0ff560ec96319729a972078ef2c755d758f0c6ef89c2d6 AS builder
|
||||
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
|
||||
+35
-14
@@ -8,14 +8,24 @@ import (
|
||||
)
|
||||
|
||||
// Quota conversions are centralized here so every billing path shares one
|
||||
// saturation + logging policy. Quota columns (user/token/log) are 32-bit
|
||||
// integers in the database, so an oversized product must clamp to the int32
|
||||
// range instead of wrapping around and turning a charge into a credit.
|
||||
// saturation + logging policy. Single-request charges stay bounded to int32;
|
||||
// top-ups and wallet-priced purchases use a JavaScript-safe 64-bit domain.
|
||||
const (
|
||||
MaxQuota = math.MaxInt32
|
||||
MinQuota = math.MinInt32
|
||||
MaxWalletQuota = 1<<53 - 1
|
||||
)
|
||||
|
||||
// ValidateWalletQuota enforces the upper bound shared by wallet mutations.
|
||||
// Negative balances remain valid because billing can temporarily overdraw a
|
||||
// wallet; callers that accept credits must apply their own positive check.
|
||||
func ValidateWalletQuota(quota int) error {
|
||||
if quota > MaxWalletQuota {
|
||||
return fmt.Errorf("wallet quota exceeds %d", MaxWalletQuota)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuotaClampKind identifies why a quota conversion had to be saturated.
|
||||
type QuotaClampKind string
|
||||
|
||||
@@ -27,11 +37,11 @@ const (
|
||||
)
|
||||
|
||||
// QuotaClamp describes a single saturation event: a quota conversion whose
|
||||
// input fell outside the representable int32 range (or was NaN) and was
|
||||
// input fell outside its supported range (or was NaN) and was
|
||||
// therefore clamped. It is surfaced to billing callers so the event can be
|
||||
// recorded on the related consume/task log for admin auditing.
|
||||
type QuotaClamp struct {
|
||||
Op string `json:"op"` // "QuotaFromFloat" | "QuotaRound" | "QuotaFromDecimal"
|
||||
Op string `json:"op"` // "QuotaFromFloat" | "QuotaRound" | "QuotaFromDecimal" | "WalletQuotaFromDecimal"
|
||||
Kind QuotaClampKind `json:"kind"` // "overflow" | "underflow" | "nan"
|
||||
Original float64 `json:"original"` // best-effort pre-clamp value (decimal -> float64 approx)
|
||||
Clamped int `json:"clamped"` // the saturated result actually used
|
||||
@@ -61,23 +71,27 @@ func (c *QuotaClamp) AuditMap() map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
// saturateQuota converts an already-rounded quota value to int, clamping to
|
||||
// the int32 range. Whenever clamping (what would otherwise be an integer
|
||||
// wraparound) or a NaN fallback is triggered it logs a warning, because in
|
||||
// saturateQuota converts an already-rounded single-request quota to int.
|
||||
// Whenever clamping (what would otherwise be an integer wraparound) or a NaN
|
||||
// fallback is triggered it logs a warning, because in
|
||||
// normal operation a single request never approaches these bounds — hitting
|
||||
// them signals a bug or an abusive request. `op` names the caller. When a
|
||||
// clamp occurs it returns a non-nil *QuotaClamp so callers can additionally
|
||||
// record the event (e.g. on the consume log); the returned pointer is nil for
|
||||
// in-range values.
|
||||
func saturateQuota(value float64, op string) (int, *QuotaClamp) {
|
||||
return saturateQuotaBounded(value, op, MaxQuota, MinQuota)
|
||||
}
|
||||
|
||||
func saturateQuotaBounded(value float64, op string, maxQuota int, minQuota int) (int, *QuotaClamp) {
|
||||
var clamp *QuotaClamp
|
||||
switch {
|
||||
case math.IsNaN(value):
|
||||
clamp = &QuotaClamp{Op: op, Kind: QuotaClampNaN, Original: value, Clamped: 0}
|
||||
case value >= MaxQuota:
|
||||
clamp = &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: value, Clamped: MaxQuota}
|
||||
case value <= MinQuota:
|
||||
clamp = &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: value, Clamped: MinQuota}
|
||||
case value > float64(maxQuota):
|
||||
clamp = &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: value, Clamped: maxQuota}
|
||||
case value < float64(minQuota):
|
||||
clamp = &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: value, Clamped: minQuota}
|
||||
default:
|
||||
return int(value), nil
|
||||
}
|
||||
@@ -147,8 +161,15 @@ func QuotaFromDecimalChecked(d decimal.Decimal) (int, *QuotaClamp) {
|
||||
return saturateQuota(f, "QuotaFromDecimal")
|
||||
}
|
||||
|
||||
// QuotaFromDecimalStrict converts an in-range decimal quota and rejects a
|
||||
// value that would otherwise be saturated at the database's int32 boundary.
|
||||
// QuotaFromDecimalStrict converts an in-range single-request quota and rejects
|
||||
// a value that would otherwise be saturated at the int32 boundary.
|
||||
func QuotaFromDecimalStrict(d decimal.Decimal) (int, error) {
|
||||
return strictQuota(QuotaFromDecimalChecked(d))
|
||||
}
|
||||
|
||||
// WalletQuotaFromDecimalStrict converts wallet and top-up values within the
|
||||
// JavaScript-safe integer range, which is also exactly representable by float64.
|
||||
func WalletQuotaFromDecimalStrict(d decimal.Decimal) (int, error) {
|
||||
f, _ := d.Round(0).Float64()
|
||||
return strictQuota(saturateQuotaBounded(f, "WalletQuotaFromDecimal", MaxWalletQuota, -MaxWalletQuota))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
@@ -21,6 +22,7 @@ func TestQuotaFromFloat(t *testing.T) {
|
||||
assert.Equal(t, 42, QuotaFromFloat(42.4))
|
||||
assert.Equal(t, 42, QuotaFromFloat(42.9))
|
||||
assert.Equal(t, -42, QuotaFromFloat(-42.9))
|
||||
assert.Equal(t, MaxQuota, QuotaFromFloat(float64(math.MaxInt32)+42))
|
||||
assert.Equal(t, MaxQuota, QuotaFromFloat(overflowingProduct))
|
||||
assert.Equal(t, MinQuota, QuotaFromFloat(-overflowingProduct))
|
||||
assert.Equal(t, MaxQuota, QuotaFromFloat(math.Inf(1)))
|
||||
@@ -34,6 +36,7 @@ func TestQuotaRound(t *testing.T) {
|
||||
assert.Equal(t, 42, QuotaRound(41.5))
|
||||
assert.Equal(t, 43, QuotaRound(42.5))
|
||||
assert.Equal(t, -43, QuotaRound(-42.5))
|
||||
assert.Equal(t, MaxQuota, QuotaRound(float64(math.MaxInt32)+0.5))
|
||||
assert.Equal(t, MaxQuota, QuotaRound(overflowingProduct))
|
||||
assert.Equal(t, MinQuota, QuotaRound(-overflowingProduct))
|
||||
assert.Equal(t, 0, QuotaRound(math.NaN()))
|
||||
@@ -93,7 +96,7 @@ func TestQuotaFromFloatStrictReturnsTypedClampError(t *testing.T) {
|
||||
assert.ErrorContains(t, err, "QuotaFromFloat")
|
||||
assert.ErrorContains(t, err, "overflow")
|
||||
assert.ErrorContains(t, err, "original=")
|
||||
assert.ErrorContains(t, err, "clamped=2147483647")
|
||||
assert.ErrorContains(t, err, fmt.Sprintf("clamped=%d", MaxQuota))
|
||||
}
|
||||
|
||||
// TestQuotaRoundChecked verifies the rounding entry point reports clamps the
|
||||
@@ -124,3 +127,20 @@ func TestQuotaFromDecimalChecked(t *testing.T) {
|
||||
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletQuotaFromDecimalStrict(t *testing.T) {
|
||||
quota, err := WalletQuotaFromDecimalStrict(decimal.NewFromInt(4_294_500_000))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 4_294_500_000, quota)
|
||||
|
||||
quota, err = WalletQuotaFromDecimalStrict(decimal.NewFromInt(MaxWalletQuota))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, MaxWalletQuota, quota)
|
||||
|
||||
quota, err = WalletQuotaFromDecimalStrict(decimal.NewFromInt(MaxWalletQuota + 1))
|
||||
assert.Zero(t, quota)
|
||||
var clamp *QuotaClamp
|
||||
require.ErrorAs(t, err, &clamp)
|
||||
assert.Equal(t, "WalletQuotaFromDecimal", clamp.Op)
|
||||
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
|
||||
}
|
||||
|
||||
+117
-40
@@ -7,11 +7,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -539,15 +539,16 @@ func settleTestQuota(info *relaycommon.RelayInfo, priceData hosttypes.PriceData,
|
||||
|
||||
quota := 0
|
||||
if !priceData.UsePrice {
|
||||
quota = usage.PromptTokens + int(math.Round(float64(usage.CompletionTokens)*priceData.CompletionRatio))
|
||||
quota = int(math.Round(float64(quota) * priceData.ModelRatio))
|
||||
completionQuota := common.QuotaRound(float64(usage.CompletionTokens) * priceData.CompletionRatio)
|
||||
quota = common.QuotaRound(float64(usage.PromptTokens) + float64(completionQuota))
|
||||
quota = common.QuotaRound(float64(quota) * priceData.ModelRatio)
|
||||
if priceData.ModelRatio != 0 && quota <= 0 {
|
||||
quota = 1
|
||||
}
|
||||
return quota, nil
|
||||
}
|
||||
|
||||
return int(priceData.ModelPrice * common.QuotaPerUnit), nil
|
||||
return common.QuotaFromFloat(priceData.ModelPrice * common.QuotaPerUnit), nil
|
||||
}
|
||||
|
||||
func buildTestLogOther(c *gin.Context, info *relaycommon.RelayInfo, priceData hosttypes.PriceData, usage *dto.Usage, tieredResult *billingexpr.TieredResult) map[string]interface{} {
|
||||
@@ -908,47 +909,24 @@ type channelTestSummary struct {
|
||||
Enabled int `json:"enabled"`
|
||||
}
|
||||
|
||||
// performChannelTests runs the channel test loop synchronously, honoring ctx
|
||||
// cancellation so a system-task runner that loses its lease stops promptly. When
|
||||
// report is non-nil it is called after each channel with (processed, total) so
|
||||
// the system task can surface progress.
|
||||
func performChannelTests(ctx context.Context, channels []*model.Channel, testUserID int, allowDisable bool, report func(processed, total int)) channelTestSummary {
|
||||
func testChannelForHealthCheck(ctx context.Context, channel *model.Channel, testUserID int, allowDisable bool, disableThreshold int64) channelTestSummary {
|
||||
summary := channelTestSummary{}
|
||||
var disableThreshold = int64(common.ChannelDisableThreshold * 1000)
|
||||
if disableThreshold == 0 {
|
||||
disableThreshold = 10000000 // a impossible value
|
||||
}
|
||||
|
||||
total := len(channels)
|
||||
for index, channel := range channels {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if report != nil {
|
||||
report(index, total) // channels completed before this one
|
||||
}
|
||||
if channel.Status == common.ChannelStatusManuallyDisabled {
|
||||
continue
|
||||
}
|
||||
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
|
||||
tik := time.Now()
|
||||
result := testChannel(ctx, channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel))
|
||||
tok := time.Now()
|
||||
milliseconds := tok.Sub(tik).Milliseconds()
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
break
|
||||
milliseconds := time.Since(tik).Milliseconds()
|
||||
if ctx.Err() != nil {
|
||||
return summary
|
||||
}
|
||||
|
||||
summary.Tested++
|
||||
|
||||
shouldBanChannel := false
|
||||
newAPIError := result.newAPIError
|
||||
// request error disables the channel
|
||||
if newAPIError != nil {
|
||||
shouldBanChannel = service.ShouldDisableChannel(result.newAPIError)
|
||||
}
|
||||
|
||||
// 当错误检查通过,才检查响应时间
|
||||
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
|
||||
if milliseconds > disableThreshold {
|
||||
err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)
|
||||
@@ -963,37 +941,135 @@ func performChannelTests(ctx context.Context, channels []*model.Channel, testUse
|
||||
summary.Failed++
|
||||
}
|
||||
|
||||
// disable channel
|
||||
if allowDisable && isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
|
||||
processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
|
||||
summary.Disabled++
|
||||
}
|
||||
|
||||
// enable channel
|
||||
if result.localErr == nil && !isChannelEnabled && service.ShouldEnableChannel(newAPIError, channel.Status) {
|
||||
service.EnableChannel(channel.Id, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.Name)
|
||||
summary.Enabled++
|
||||
}
|
||||
|
||||
channel.UpdateResponseTime(milliseconds)
|
||||
if common.RequestInterval > 0 {
|
||||
return summary
|
||||
}
|
||||
|
||||
// runChannelTestWorkers executes independent channel tests with bounded
|
||||
// concurrency. Results and progress are reduced by the caller goroutine, so
|
||||
// summary counts and the progress reporter remain serialized.
|
||||
func runChannelTestWorkers(
|
||||
ctx context.Context,
|
||||
channels []*model.Channel,
|
||||
concurrency int,
|
||||
run func(context.Context, *model.Channel) channelTestSummary,
|
||||
report func(processed, total int),
|
||||
) channelTestSummary {
|
||||
if ctx == nil {
|
||||
time.Sleep(common.RequestInterval)
|
||||
} else {
|
||||
ctx = context.Background()
|
||||
}
|
||||
total := len(channels)
|
||||
if report != nil {
|
||||
report(0, total)
|
||||
}
|
||||
if total == 0 {
|
||||
return channelTestSummary{}
|
||||
}
|
||||
|
||||
workerCount := min(operation_setting.NormalizeChannelTestConcurrency(concurrency), total)
|
||||
jobs := make(chan *model.Channel)
|
||||
results := make(chan channelTestSummary)
|
||||
|
||||
var workers sync.WaitGroup
|
||||
workers.Add(workerCount)
|
||||
for range workerCount {
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return summary
|
||||
return
|
||||
case channel, ok := <-jobs:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
result := channelTestSummary{}
|
||||
if channel != nil && channel.Status != common.ChannelStatusManuallyDisabled {
|
||||
result = run(ctx, channel)
|
||||
}
|
||||
|
||||
results <- result
|
||||
|
||||
if common.RequestInterval > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(common.RequestInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if report != nil && (ctx == nil || ctx.Err() == nil) {
|
||||
report(total, total) // mark complete only when the full set was tested
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(jobs)
|
||||
for _, channel := range channels {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case jobs <- channel:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
workers.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
summary := channelTestSummary{}
|
||||
processed := 0
|
||||
for result := range results {
|
||||
summary.Tested += result.Tested
|
||||
summary.Succeeded += result.Succeeded
|
||||
summary.Failed += result.Failed
|
||||
summary.Disabled += result.Disabled
|
||||
summary.Enabled += result.Enabled
|
||||
processed++
|
||||
if report != nil && ctx.Err() == nil {
|
||||
report(processed, total)
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// performChannelTests runs channel health checks with the configured bounded
|
||||
// concurrency and honors cancellation when a system-task runner loses its
|
||||
// lease.
|
||||
func performChannelTests(ctx context.Context, channels []*model.Channel, testUserID int, allowDisable bool, concurrency int, report func(processed, total int)) channelTestSummary {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
disableThreshold := int64(common.ChannelDisableThreshold * 1000)
|
||||
if disableThreshold == 0 {
|
||||
disableThreshold = 10000000 // an impossible value
|
||||
}
|
||||
return runChannelTestWorkers(
|
||||
ctx,
|
||||
channels,
|
||||
concurrency,
|
||||
func(ctx context.Context, channel *model.Channel) channelTestSummary {
|
||||
return testChannelForHealthCheck(ctx, channel, testUserID, allowDisable, disableThreshold)
|
||||
},
|
||||
report,
|
||||
)
|
||||
}
|
||||
|
||||
// runChannelTestTask runs one synchronous channel test cycle for the system task
|
||||
// runner (both the scheduled job and the manual "test all channels" trigger go
|
||||
// through here). It honors ctx cancellation so a runner that loses its lease
|
||||
@@ -1016,7 +1092,8 @@ func runChannelTestTask(ctx context.Context, mode string, notify bool, report fu
|
||||
}
|
||||
selected := selectChannelsForAutomaticTest(channels, mode)
|
||||
allowDisable := mode != operation_setting.ChannelTestModePassiveRecovery
|
||||
summary := performChannelTests(ctx, selected, testUserID, allowDisable, report)
|
||||
concurrency := operation_setting.GetMonitorSetting().ChannelTestConcurrency
|
||||
summary := performChannelTests(ctx, selected, testUserID, allowDisable, concurrency, report)
|
||||
if notify && (ctx == nil || ctx.Err() == nil) {
|
||||
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -339,6 +341,111 @@ func TestSelectChannelsForAutomaticTestAutoBanOnlyUsesEligibleChannels(t *testin
|
||||
require.Equal(t, 3, selected[1].Id)
|
||||
}
|
||||
|
||||
func TestRunChannelTestWorkersHonorsConfiguredConcurrency(t *testing.T) {
|
||||
originalInterval := common.RequestInterval
|
||||
common.RequestInterval = 0
|
||||
t.Cleanup(func() { common.RequestInterval = originalInterval })
|
||||
|
||||
channels := []*model.Channel{
|
||||
{Id: 1, Status: common.ChannelStatusEnabled},
|
||||
{Id: 2, Status: common.ChannelStatusEnabled},
|
||||
{Id: 3, Status: common.ChannelStatusEnabled},
|
||||
{Id: 4, Status: common.ChannelStatusEnabled},
|
||||
}
|
||||
started := make(chan struct{}, len(channels))
|
||||
release := make(chan struct{})
|
||||
var active atomic.Int32
|
||||
var maxActive atomic.Int32
|
||||
progress := make([]int, 0, len(channels)+1)
|
||||
summaryResult := make(chan channelTestSummary, 1)
|
||||
|
||||
go func() {
|
||||
summaryResult <- runChannelTestWorkers(
|
||||
context.Background(),
|
||||
channels,
|
||||
2,
|
||||
func(_ context.Context, _ *model.Channel) channelTestSummary {
|
||||
current := active.Add(1)
|
||||
defer active.Add(-1)
|
||||
for {
|
||||
observed := maxActive.Load()
|
||||
if current <= observed || maxActive.CompareAndSwap(observed, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
started <- struct{}{}
|
||||
<-release
|
||||
return channelTestSummary{Tested: 1, Succeeded: 1}
|
||||
},
|
||||
func(processed, _ int) {
|
||||
progress = append(progress, processed)
|
||||
},
|
||||
)
|
||||
}()
|
||||
|
||||
<-started
|
||||
<-started
|
||||
select {
|
||||
case <-started:
|
||||
t.Fatal("started more channel tests than the configured concurrency")
|
||||
default:
|
||||
}
|
||||
close(release)
|
||||
|
||||
summary := <-summaryResult
|
||||
|
||||
assert.Equal(t, int32(2), maxActive.Load())
|
||||
assert.Equal(t, channelTestSummary{Tested: 4, Succeeded: 4}, summary)
|
||||
assert.Equal(t, []int{0, 1, 2, 3, 4}, progress)
|
||||
}
|
||||
|
||||
func TestRunChannelTestWorkersStopsAfterCancellation(t *testing.T) {
|
||||
originalInterval := common.RequestInterval
|
||||
common.RequestInterval = 0
|
||||
t.Cleanup(func() { common.RequestInterval = originalInterval })
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
channels := []*model.Channel{
|
||||
{Id: 1, Status: common.ChannelStatusEnabled},
|
||||
{Id: 2, Status: common.ChannelStatusEnabled},
|
||||
{Id: 3, Status: common.ChannelStatusEnabled},
|
||||
{Id: 4, Status: common.ChannelStatusEnabled},
|
||||
}
|
||||
started := make(chan struct{}, len(channels))
|
||||
progress := make([]int, 0, 1)
|
||||
summaryResult := make(chan channelTestSummary, 1)
|
||||
|
||||
go func() {
|
||||
summaryResult <- runChannelTestWorkers(
|
||||
ctx,
|
||||
channels,
|
||||
2,
|
||||
func(ctx context.Context, _ *model.Channel) channelTestSummary {
|
||||
started <- struct{}{}
|
||||
<-ctx.Done()
|
||||
return channelTestSummary{Tested: 1, Succeeded: 1}
|
||||
},
|
||||
func(processed, _ int) {
|
||||
progress = append(progress, processed)
|
||||
},
|
||||
)
|
||||
}()
|
||||
|
||||
<-started
|
||||
<-started
|
||||
cancel()
|
||||
|
||||
summary := <-summaryResult
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
t.Fatal("started another channel test after cancellation")
|
||||
default:
|
||||
}
|
||||
assert.Equal(t, channelTestSummary{Tested: 2, Succeeded: 2}, summary)
|
||||
assert.Equal(t, []int{0}, progress)
|
||||
}
|
||||
|
||||
func TestTestAllChannelsRejectsExistingActiveTask(t *testing.T) {
|
||||
db := setupModelListControllerTestDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&model.SystemTask{}, &model.SystemTaskLock{}))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"unicode/utf8"
|
||||
@@ -85,6 +86,14 @@ func AddRedemption(c *gin.Context) {
|
||||
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
|
||||
return
|
||||
}
|
||||
if redemption.Quota <= 0 {
|
||||
common.ApiError(c, errors.New("redemption quota must be positive"))
|
||||
return
|
||||
}
|
||||
if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
||||
return
|
||||
@@ -153,6 +162,14 @@ func UpdateRedemption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if statusOnly == "" {
|
||||
if redemption.Quota <= 0 {
|
||||
common.ApiError(c, errors.New("redemption quota must be positive"))
|
||||
return
|
||||
}
|
||||
if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
||||
return
|
||||
|
||||
+13
-2
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type tokenAutoGroupsInput struct {
|
||||
@@ -41,6 +42,16 @@ type tokenResponse struct {
|
||||
AutoGroups []string `json:"auto_groups"`
|
||||
}
|
||||
|
||||
func maxTokenQuota() int {
|
||||
quota, err := common.WalletQuotaFromDecimalStrict(
|
||||
decimal.NewFromInt(1_000_000_000).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||
)
|
||||
if err != nil {
|
||||
return common.MaxWalletQuota
|
||||
}
|
||||
return quota
|
||||
}
|
||||
|
||||
func buildMaskedTokenResponse(token *model.Token) *tokenResponse {
|
||||
if token == nil {
|
||||
return nil
|
||||
@@ -279,7 +290,7 @@ func AddToken(c *gin.Context) {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
|
||||
return
|
||||
}
|
||||
maxQuotaValue := common.QuotaFromFloat(1000000000 * common.QuotaPerUnit)
|
||||
maxQuotaValue := maxTokenQuota()
|
||||
if token.RemainQuota > maxQuotaValue {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
|
||||
return
|
||||
@@ -373,7 +384,7 @@ func UpdateToken(c *gin.Context) {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
|
||||
return
|
||||
}
|
||||
maxQuotaValue := common.QuotaFromFloat(1000000000 * common.QuotaPerUnit)
|
||||
maxQuotaValue := maxTokenQuota()
|
||||
if token.RemainQuota > maxQuotaValue {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenQuotaExceedMax, map[string]any{"Max": maxQuotaValue})
|
||||
return
|
||||
|
||||
+8
-4
@@ -182,7 +182,11 @@ func getMinTopup() int64 {
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
dMinTopup := decimal.NewFromInt(int64(minTopup))
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
minTopup = common.QuotaFromDecimal(dMinTopup.Mul(dQuotaPerUnit))
|
||||
quota, err := common.WalletQuotaFromDecimalStrict(dMinTopup.Mul(dQuotaPerUnit))
|
||||
if err != nil {
|
||||
return common.MaxWalletQuota
|
||||
}
|
||||
minTopup = quota
|
||||
}
|
||||
return int64(minTopup)
|
||||
}
|
||||
@@ -195,7 +199,7 @@ func getTopUpQuota(amount int64) (int, error) {
|
||||
} else {
|
||||
quota = quota.Mul(decimal.NewFromFloat(common.QuotaPerUnit))
|
||||
}
|
||||
return common.QuotaFromDecimalStrict(quota)
|
||||
return common.WalletQuotaFromDecimalStrict(quota)
|
||||
}
|
||||
|
||||
func getMaxTopUpAmount() int64 {
|
||||
@@ -203,7 +207,7 @@ func getMaxTopUpAmount() int64 {
|
||||
return 0
|
||||
}
|
||||
quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
maxStoredAmount := decimal.NewFromInt(common.MaxQuota - 1).
|
||||
maxStoredAmount := decimal.NewFromInt(common.MaxWalletQuota).
|
||||
Div(quotaPerUnit).
|
||||
Floor()
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
@@ -217,7 +221,7 @@ func getMaxTopUpAmount() int64 {
|
||||
}
|
||||
|
||||
func validateCreditedQuota(quota decimal.Decimal) (int, error) {
|
||||
value, err := common.QuotaFromDecimalStrict(quota)
|
||||
value, err := common.WalletQuotaFromDecimalStrict(quota)
|
||||
if err != nil {
|
||||
return 0, errors.New("充值额度超出系统可表示范围")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -43,19 +44,19 @@ func TestTopUpQuotaValidation(t *testing.T) {
|
||||
name: "currency amount above limit",
|
||||
displayType: operation_setting.QuotaDisplayTypeUSD,
|
||||
amount: 4295,
|
||||
wantErr: true,
|
||||
wantQuota: 2_147_500_000,
|
||||
},
|
||||
{
|
||||
name: "token amount preserves settlement truncation",
|
||||
displayType: operation_setting.QuotaDisplayTypeTokens,
|
||||
amount: common.MaxQuota,
|
||||
wantQuota: 2_147_000_000,
|
||||
amount: 2_147_500_000,
|
||||
wantQuota: 2_147_500_000,
|
||||
},
|
||||
{
|
||||
name: "token amount above settlement limit",
|
||||
name: "token amount above legacy int32 range",
|
||||
displayType: operation_setting.QuotaDisplayTypeTokens,
|
||||
amount: 2_147_500_000,
|
||||
wantErr: true,
|
||||
amount: 4_294_500_000,
|
||||
wantQuota: 4_294_500_000,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -83,14 +84,14 @@ func TestValidateTopUpQuotaReturnsMaximumAmount(t *testing.T) {
|
||||
operation_setting.GetGeneralSetting().QuotaDisplayType = oldDisplayType
|
||||
})
|
||||
|
||||
maxAmount := decimal.NewFromInt(common.MaxQuota - 1).
|
||||
maxAmount := decimal.NewFromInt(common.MaxWalletQuota).
|
||||
Div(decimal.NewFromFloat(common.QuotaPerUnit)).
|
||||
Floor().IntPart()
|
||||
|
||||
_, err := validateTopUpQuota(maxAmount)
|
||||
require.NoError(t, err)
|
||||
_, err = validateTopUpQuota(maxAmount + 1)
|
||||
require.EqualError(t, err, "单笔充值数量不能大于 4294")
|
||||
require.EqualError(t, err, fmt.Sprintf("单笔充值数量不能大于 %d", maxAmount))
|
||||
}
|
||||
|
||||
func TestRequestAmountRejectsTopUpThatCannotBeSettled(t *testing.T) {
|
||||
@@ -106,17 +107,20 @@ func TestRequestAmountRejectsTopUpThatCannotBeSettled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
maxAmount := decimal.NewFromInt(common.MaxWalletQuota).
|
||||
Div(decimal.NewFromFloat(common.QuotaPerUnit)).
|
||||
Floor().IntPart()
|
||||
ctx.Request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/user/amount",
|
||||
strings.NewReader(`{"amount":4295}`),
|
||||
strings.NewReader(fmt.Sprintf(`{"amount":%d}`, maxAmount+1)),
|
||||
)
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
RequestAmount(ctx)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.JSONEq(t, `{"message":"error","data":"单笔充值数量不能大于 4294"}`, recorder.Body.String())
|
||||
assert.JSONEq(t, fmt.Sprintf(`{"message":"error","data":"单笔充值数量不能大于 %d"}`, maxAmount), recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
|
||||
@@ -143,7 +147,7 @@ func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
|
||||
require.NoError(t, model.DB.Create(&model.User{
|
||||
Id: 42,
|
||||
Username: "topup_capacity_user",
|
||||
Quota: 1_000_000,
|
||||
Quota: common.MaxWalletQuota - 100_000,
|
||||
Status: common.UserStatusEnabled,
|
||||
}).Error)
|
||||
|
||||
@@ -154,7 +158,7 @@ func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
|
||||
ctx.Request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/user/amount",
|
||||
strings.NewReader(`{"amount":4294}`),
|
||||
strings.NewReader(`{"amount":1}`),
|
||||
)
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -165,11 +169,11 @@ func TestRequestAmountRejectsTopUpThatWouldOverflowWallet(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateCreditedQuotaRejectsOverflow(t *testing.T) {
|
||||
_, err := validateCreditedQuota(decimal.NewFromInt(common.MaxQuota - 1))
|
||||
_, err := validateCreditedQuota(decimal.NewFromInt(int64(common.MaxWalletQuota / 2)))
|
||||
require.NoError(t, err)
|
||||
_, err = validateCreditedQuota(decimal.Zero)
|
||||
require.EqualError(t, err, "充值额度必须大于 0")
|
||||
_, err = validateCreditedQuota(decimal.NewFromInt(common.MaxQuota))
|
||||
_, err = validateCreditedQuota(decimal.NewFromInt(common.MaxWalletQuota + 1))
|
||||
require.EqualError(
|
||||
t,
|
||||
err,
|
||||
@@ -190,6 +194,8 @@ func TestStripeCreditedQuotaIncludesGroupRatio(t *testing.T) {
|
||||
_, err := validateCreditedQuota(getStripeCreditedQuota(2147, "vip"))
|
||||
require.NoError(t, err)
|
||||
_, err = validateCreditedQuota(getStripeCreditedQuota(2148, "vip"))
|
||||
require.NoError(t, err)
|
||||
_, err = validateCreditedQuota(getStripeCreditedQuota(int64(common.MaxWalletQuota), "vip"))
|
||||
require.Error(t, err)
|
||||
|
||||
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(`{"free":0}`))
|
||||
|
||||
@@ -1167,6 +1167,10 @@ func ManageUser(c *gin.Context) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
|
||||
return
|
||||
}
|
||||
if err := common.ValidateWalletQuota(req.Value); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.IncreaseUserQuota(user.Id, req.Value, true); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
@@ -1187,6 +1191,10 @@ func ManageUser(c *gin.Context) {
|
||||
"quota": logger.LogQuota(req.Value),
|
||||
})
|
||||
case "override":
|
||||
if err := common.ValidateWalletQuota(req.Value); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
oldQuota := user.Quota
|
||||
if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
|
||||
@@ -159,3 +159,24 @@ func TestManageUserDeleteReturnsImmediatelyAndUnknownActionFails(t *testing.T) {
|
||||
assert.EqualValues(t, 1, unchanged.AuthVersion)
|
||||
assert.Equal(t, common.UserStatusEnabled, unchanged.Status)
|
||||
}
|
||||
|
||||
func TestManageUserQuotaRespectsWalletCeiling(t *testing.T) {
|
||||
db := setupManageUserTestDB(t)
|
||||
user := model.User{
|
||||
Username: "managed-quota-user", Password: "password", Role: common.RoleCommonUser,
|
||||
Status: common.UserStatusEnabled, Group: "default", Quota: common.MaxWalletQuota - 1,
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":"add","value":2}`, user.Id))
|
||||
assert.Contains(t, recorder.Body.String(), `"success":false`)
|
||||
|
||||
var updated model.User
|
||||
require.NoError(t, db.First(&updated, user.Id).Error)
|
||||
assert.Equal(t, common.MaxWalletQuota-1, updated.Quota)
|
||||
|
||||
recorder = performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":"override","value":%d}`, user.Id, common.MaxWalletQuota+1))
|
||||
assert.Contains(t, recorder.Body.String(), `"success":false`)
|
||||
require.NoError(t, db.First(&updated, user.Id).Error)
|
||||
assert.Equal(t, common.MaxWalletQuota-1, updated.Quota)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -103,7 +104,7 @@ func redisRateLimitHandler(duration int64, totalMaxCount, successMaxCount int) g
|
||||
allowed, err = tb.Allow(
|
||||
ctx,
|
||||
totalKey,
|
||||
limiter.WithCapacity(int64(totalMaxCount)*duration),
|
||||
limiter.WithCapacity(rateLimitCapacity(totalMaxCount, duration)),
|
||||
limiter.WithRate(int64(totalMaxCount)),
|
||||
limiter.WithRequested(duration),
|
||||
)
|
||||
@@ -174,7 +175,7 @@ func ModelRequestRateLimit() func(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 计算限流参数
|
||||
duration := int64(setting.ModelRequestRateLimitDurationMinutes * 60)
|
||||
duration := rateLimitDurationSeconds(setting.ModelRequestRateLimitDurationMinutes)
|
||||
totalMaxCount := setting.ModelRequestRateLimitCount
|
||||
successMaxCount := setting.ModelRequestRateLimitSuccessCount
|
||||
|
||||
@@ -199,3 +200,25 @@ func ModelRequestRateLimit() func(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rateLimitDurationSeconds(durationMinutes int) int64 {
|
||||
if durationMinutes <= 0 {
|
||||
return 0
|
||||
}
|
||||
minutes := int64(durationMinutes)
|
||||
if minutes > math.MaxInt64/60 {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return minutes * 60
|
||||
}
|
||||
|
||||
func rateLimitCapacity(count int, durationSeconds int64) int64 {
|
||||
if count <= 0 || durationSeconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
c := int64(count)
|
||||
if c > math.MaxInt64/durationSeconds {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return c * durationSeconds
|
||||
}
|
||||
|
||||
@@ -186,6 +186,9 @@ func InitDB() (err error) {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
if err := ensureUserQuotaColumns(DB, common.MainDatabaseType()); err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB, err := DB.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -250,6 +253,52 @@ func InitLogDB() (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
var userQuotaColumns = []string{"quota", "used_quota", "aff_quota", "aff_history"}
|
||||
|
||||
// ensureUserQuotaColumns rejects a legacy 32-bit wallet schema before any
|
||||
// migrations run. The 64-bit-only build intentionally does not auto-upgrade
|
||||
// an existing wallet; operators must migrate it explicitly before starting.
|
||||
func ensureUserQuotaColumns(db *gorm.DB, dbType common.DatabaseType) error {
|
||||
if common.GetEnvOrDefaultBool("SKIP_64BIT_QUOTA_SCHEMA_CHECK", false) {
|
||||
common.SysLog("SKIP_64BIT_QUOTA_SCHEMA_CHECK=true; skipping user quota schema check")
|
||||
return nil
|
||||
}
|
||||
if db == nil || dbType == common.DatabaseTypeSQLite {
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasTable(&User{}) {
|
||||
return nil
|
||||
}
|
||||
columnTypes, err := db.Migrator().ColumnTypes(&User{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to inspect users schema: %w", err)
|
||||
}
|
||||
for _, expected := range userQuotaColumns {
|
||||
for _, actual := range columnTypes {
|
||||
if !strings.EqualFold(actual.Name(), expected) {
|
||||
continue
|
||||
}
|
||||
dataType := actual.DatabaseTypeName()
|
||||
if !is64BitIntegerType(dbType, dataType) {
|
||||
return fmt.Errorf("users.%s uses %s; 32-bit is not supported", expected, dataType)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func is64BitIntegerType(dbType common.DatabaseType, dataType string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(dataType))
|
||||
switch dbType {
|
||||
case common.DatabaseTypeMySQL:
|
||||
return normalized == "bigint" || normalized == "unsigned bigint" || normalized == "bigint unsigned"
|
||||
case common.DatabaseTypePostgreSQL:
|
||||
return normalized == "bigint" || normalized == "int8"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func migrateDB() error {
|
||||
// Migrate price_amount column from float/double to decimal for existing tables
|
||||
migrateSubscriptionPlanPriceAmount()
|
||||
|
||||
@@ -209,6 +209,9 @@ func validateOptionValue(key string, value string) error {
|
||||
if key == operation_setting.ToolPriceOptionKey {
|
||||
return operation_setting.ValidateToolPricesJSON(value)
|
||||
}
|
||||
if key == operation_setting.ChannelTestConcurrencyOptionKey {
|
||||
return operation_setting.ValidateChannelTestConcurrency(value)
|
||||
}
|
||||
if key == "MaxTokenAutoGroups" {
|
||||
return setting.ValidateMaxTokenAutoGroups(value)
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ func TestRechargeEpayRejectsQuotaOverflowBeforeCompletingOrder(t *testing.T) {
|
||||
truncateTables(t)
|
||||
|
||||
oldQuotaPerUnit := common.QuotaPerUnit
|
||||
common.QuotaPerUnit = float64(common.MaxQuota)
|
||||
common.QuotaPerUnit = float64(common.MaxWalletQuota + 1)
|
||||
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
|
||||
|
||||
user := insertUserForPaymentGuardTest(t, 505, 3)
|
||||
@@ -323,15 +323,15 @@ func TestRechargeEpayEnforcesFinalWalletQuotaLimit(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "allows exact highest representable wallet balance",
|
||||
currentQuota: common.MaxQuota - 1 - 1_000_000,
|
||||
wantQuota: common.MaxQuota - 1,
|
||||
currentQuota: common.MaxWalletQuota - 1_000_000,
|
||||
wantQuota: common.MaxWalletQuota,
|
||||
wantStatus: common.TopUpStatusSuccess,
|
||||
},
|
||||
{
|
||||
name: "rejects balance above int32 quota domain",
|
||||
currentQuota: common.MaxQuota - 1_000_000,
|
||||
name: "rejects balance above wallet quota domain",
|
||||
currentQuota: common.MaxWalletQuota - 999_999,
|
||||
wantErr: true,
|
||||
wantQuota: common.MaxQuota - 1_000_000,
|
||||
wantQuota: common.MaxWalletQuota - 999_999,
|
||||
wantStatus: common.TopUpStatusPending,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -137,6 +138,38 @@ func TestRedisBatchReserveNeverFallsBackToStaleDatabaseBalance(t *testing.T) {
|
||||
assert.Equal(t, 7, reloadedToken.UsedQuota)
|
||||
}
|
||||
|
||||
func TestBatchUpdateAccumulatesTwoMaximumRequestCharges(t *testing.T) {
|
||||
truncateTables(t)
|
||||
resetBatchUpdateTestState(t)
|
||||
common.BatchUpdateEnabled = true
|
||||
|
||||
user := createReserveTestUser(t, common.MaxQuota*2+100)
|
||||
require.NoError(t, DecreaseUserQuota(user.Id, common.MaxQuota, false))
|
||||
require.NoError(t, DecreaseUserQuota(user.Id, common.MaxQuota, false))
|
||||
|
||||
batchUpdate()
|
||||
assert.Equal(t, 100, getUserQuotaFromDB(t, user.Id))
|
||||
}
|
||||
|
||||
func TestBatchUpdateAccumulatorSaturatesOverflow(t *testing.T) {
|
||||
resetBatchUpdateTestState(t)
|
||||
|
||||
addNewRecord(BatchUpdateTypeUserQuota, 1, math.MaxInt)
|
||||
addNewRecord(BatchUpdateTypeUserQuota, 1, 1)
|
||||
batchUpdateLocks[BatchUpdateTypeUserQuota].Lock()
|
||||
assert.Equal(t, math.MaxInt, batchUpdateStores[BatchUpdateTypeUserQuota][1])
|
||||
batchUpdateLocks[BatchUpdateTypeUserQuota].Unlock()
|
||||
|
||||
batchUpdateLocks[BatchUpdateTypeUserQuota].Lock()
|
||||
batchUpdateStores[BatchUpdateTypeUserQuota] = make(map[int]int)
|
||||
batchUpdateLocks[BatchUpdateTypeUserQuota].Unlock()
|
||||
addNewRecord(BatchUpdateTypeUserQuota, 1, math.MinInt)
|
||||
addNewRecord(BatchUpdateTypeUserQuota, 1, -1)
|
||||
batchUpdateLocks[BatchUpdateTypeUserQuota].Lock()
|
||||
assert.Equal(t, math.MinInt, batchUpdateStores[BatchUpdateTypeUserQuota][1])
|
||||
batchUpdateLocks[BatchUpdateTypeUserQuota].Unlock()
|
||||
}
|
||||
|
||||
func TestReserveFallsBackToDatabaseWhenRedisIsUnavailable(t *testing.T) {
|
||||
truncateTables(t)
|
||||
resetBatchUpdateTestState(t)
|
||||
|
||||
+13
-1
@@ -175,7 +175,7 @@ func Redeem(key string, userId int) (quota int, err error) {
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("该兑换码已被使用")
|
||||
}
|
||||
return tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
|
||||
return creditTopUpQuota(tx, userId, redemption.Quota, nil)
|
||||
})
|
||||
if err != nil {
|
||||
common.SysError("redemption failed: " + err.Error())
|
||||
@@ -187,6 +187,12 @@ func Redeem(key string, userId int) (quota int, err error) {
|
||||
}
|
||||
|
||||
func (redemption *Redemption) Insert() error {
|
||||
if redemption.Quota <= 0 {
|
||||
return errors.New("redemption quota must be positive")
|
||||
}
|
||||
if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
err = DB.Create(redemption).Error
|
||||
return err
|
||||
@@ -199,6 +205,12 @@ func (redemption *Redemption) SelectUpdate() error {
|
||||
|
||||
// Update Make sure your token's fields is completed, because this will update non-zero values
|
||||
func (redemption *Redemption) Update() error {
|
||||
if redemption.Quota <= 0 {
|
||||
return errors.New("redemption quota must be positive")
|
||||
}
|
||||
if err := common.ValidateWalletQuota(redemption.Quota); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error
|
||||
return err
|
||||
|
||||
@@ -148,6 +148,35 @@ func TestRedeemCreditsQuotaExactlyOnce(t *testing.T) {
|
||||
assert.Equal(t, 500, user.Quota)
|
||||
}
|
||||
|
||||
func TestRedeemRejectsWalletOverflow(t *testing.T) {
|
||||
userId, key := setupRedeemFixture(t, 11)
|
||||
require.NoError(t, DB.Model(&User{}).Where("id = ?", userId).Update("quota", common.MaxWalletQuota-10).Error)
|
||||
|
||||
_, err := Redeem(key, userId)
|
||||
require.ErrorIs(t, err, ErrRedeemFailed)
|
||||
|
||||
var user User
|
||||
require.NoError(t, DB.First(&user, "id = ?", userId).Error)
|
||||
assert.Equal(t, common.MaxWalletQuota-10, user.Quota)
|
||||
|
||||
var redemption Redemption
|
||||
require.NoError(t, DB.First(&redemption, "key = ?", key).Error)
|
||||
assert.Equal(t, common.RedemptionCodeStatusEnabled, redemption.Status)
|
||||
}
|
||||
|
||||
func TestRedemptionQuotaRejectsWalletOverflow(t *testing.T) {
|
||||
setupRedeemFixture(t, 500)
|
||||
|
||||
redemption := &Redemption{
|
||||
Name: "overflow-redemption",
|
||||
Key: "10000000000000000000000000000002",
|
||||
Status: common.RedemptionCodeStatusEnabled,
|
||||
Quota: common.MaxWalletQuota + 1,
|
||||
CreatedTime: common.GetTimestamp(),
|
||||
}
|
||||
require.Error(t, redemption.Insert())
|
||||
}
|
||||
|
||||
// Exactly one of several concurrent redeems of the same code may win, and
|
||||
// quota must be credited exactly once.
|
||||
func TestRedeemConcurrentSingleSuccess(t *testing.T) {
|
||||
|
||||
@@ -749,7 +749,7 @@ func calcSubscriptionBalanceQuota(priceAmount float64) (int, error) {
|
||||
quota := decimal.NewFromFloat(priceAmount).
|
||||
Mul(decimal.NewFromFloat(common.QuotaPerUnit)).
|
||||
Ceil()
|
||||
return common.QuotaFromDecimalStrict(quota)
|
||||
return common.WalletQuotaFromDecimalStrict(quota)
|
||||
}
|
||||
|
||||
// PurchaseSubscriptionWithBalance creates a subscription by deducting the user's wallet quota.
|
||||
|
||||
+12
-11
@@ -47,6 +47,7 @@ var (
|
||||
ErrTopUpStatusInvalid = errors.New("topup status invalid")
|
||||
ErrInvalidTopUpQuota = errors.New("invalid top-up quota")
|
||||
ErrTopUpQuotaLimitExceeded = errors.New("top-up quota limit exceeded")
|
||||
ErrWalletQuotaLimitExceeded = errors.New("wallet quota limit exceeded")
|
||||
)
|
||||
|
||||
func (topUp *TopUp) Insert() error {
|
||||
@@ -56,10 +57,10 @@ func (topUp *TopUp) Insert() error {
|
||||
}
|
||||
|
||||
func topUpQuotaMaxCurrent(creditedQuota int) (int, error) {
|
||||
if creditedQuota <= 0 || creditedQuota >= common.MaxQuota {
|
||||
if creditedQuota <= 0 || creditedQuota > common.MaxWalletQuota {
|
||||
return 0, ErrInvalidTopUpQuota
|
||||
}
|
||||
return common.MaxQuota - 1 - creditedQuota, nil
|
||||
return common.MaxWalletQuota - creditedQuota, nil
|
||||
}
|
||||
|
||||
// ValidateTopUpQuotaCapacity performs the user-facing pre-payment check. The
|
||||
@@ -81,8 +82,8 @@ func ValidateTopUpQuotaCapacity(userId int, creditedQuota int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// creditTopUpQuota atomically enforces the int32 wallet ceiling while adding
|
||||
// quota. Keeping the predicate and increment in one UPDATE prevents two
|
||||
// creditTopUpQuota atomically enforces the wallet ceiling while adding quota.
|
||||
// Keeping the predicate and increment in one UPDATE prevents two
|
||||
// concurrent callbacks from both passing a separate read/check.
|
||||
func creditTopUpQuota(tx *gorm.DB, userId int, creditedQuota int, updates map[string]interface{}) error {
|
||||
maxCurrentQuota, err := topUpQuotaMaxCurrent(creditedQuota)
|
||||
@@ -203,7 +204,7 @@ func RechargeEpay(tradeNo string, actualPaymentMethod string, callerIp string) (
|
||||
topUp.PaymentMethod = actualPaymentMethod
|
||||
}
|
||||
var quotaErr error
|
||||
quotaToAdd, quotaErr = common.QuotaFromDecimalStrict(
|
||||
quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
|
||||
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||
)
|
||||
if quotaErr != nil || quotaToAdd <= 0 {
|
||||
@@ -266,7 +267,7 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error
|
||||
return err
|
||||
}
|
||||
|
||||
quota, err = common.QuotaFromDecimalStrict(
|
||||
quota, err = common.WalletQuotaFromDecimalStrict(
|
||||
decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||
)
|
||||
if err != nil || quota <= 0 {
|
||||
@@ -482,11 +483,11 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error {
|
||||
// - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit
|
||||
var quotaErr error
|
||||
if topUp.PaymentProvider == PaymentProviderStripe {
|
||||
quotaToAdd, quotaErr = common.QuotaFromDecimalStrict(
|
||||
quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
|
||||
decimal.NewFromFloat(topUp.Money).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||
)
|
||||
} else {
|
||||
quotaToAdd, quotaErr = common.QuotaFromDecimalStrict(
|
||||
quotaToAdd, quotaErr = common.WalletQuotaFromDecimalStrict(
|
||||
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||
)
|
||||
}
|
||||
@@ -556,7 +557,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
|
||||
}
|
||||
|
||||
// Creem 直接使用 Amount 作为充值额度(整数)
|
||||
quota, err = common.QuotaFromDecimalStrict(decimal.NewFromInt(topUp.Amount))
|
||||
quota, err = common.WalletQuotaFromDecimalStrict(decimal.NewFromInt(topUp.Amount))
|
||||
if err != nil || quota <= 0 {
|
||||
return ErrInvalidTopUpQuota
|
||||
}
|
||||
@@ -624,7 +625,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) {
|
||||
return errors.New("充值订单状态错误")
|
||||
}
|
||||
|
||||
quotaToAdd, err = common.QuotaFromDecimalStrict(
|
||||
quotaToAdd, err = common.WalletQuotaFromDecimalStrict(
|
||||
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||
)
|
||||
if err != nil || quotaToAdd <= 0 {
|
||||
@@ -684,7 +685,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) {
|
||||
return errors.New("充值订单状态错误")
|
||||
}
|
||||
|
||||
quotaToAdd, err = common.QuotaFromDecimalStrict(
|
||||
quotaToAdd, err = common.WalletQuotaFromDecimalStrict(
|
||||
decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)),
|
||||
)
|
||||
if err != nil || quotaToAdd <= 0 {
|
||||
|
||||
+30
-8
@@ -1271,25 +1271,47 @@ func IncreaseUserQuota(id int, quota int, db bool) (err error) {
|
||||
if quota < 0 {
|
||||
return errors.New("quota 不能为负数!")
|
||||
}
|
||||
if err := common.ValidateWalletQuota(quota); err != nil {
|
||||
return err
|
||||
}
|
||||
if !db && common.BatchUpdateEnabled {
|
||||
addNewRecord(BatchUpdateTypeUserQuota, id, quota)
|
||||
gopool.Go(func() {
|
||||
err := cacheIncrUserQuota(id, int64(quota))
|
||||
if err != nil {
|
||||
if err := cacheIncrUserQuota(id, int64(quota)); err != nil {
|
||||
common.SysLog("failed to increase user quota: " + err.Error())
|
||||
}
|
||||
})
|
||||
if !db && common.BatchUpdateEnabled {
|
||||
addNewRecord(BatchUpdateTypeUserQuota, id, quota)
|
||||
return nil
|
||||
}
|
||||
return increaseUserQuota(id, quota)
|
||||
if err := increaseUserQuota(id, quota); err != nil {
|
||||
return err
|
||||
}
|
||||
gopool.Go(func() {
|
||||
if err := cacheIncrUserQuota(id, int64(quota)); err != nil {
|
||||
common.SysLog("failed to increase user quota: " + err.Error())
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func increaseUserQuota(id int, quota int) (err error) {
|
||||
err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota + ?", quota)).Error
|
||||
if err != nil {
|
||||
result := DB.Model(&User{}).
|
||||
Where("id = ? AND quota <= ?", id, common.MaxWalletQuota-quota).
|
||||
Update("quota", gorm.Expr("quota + ?", quota))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 1 {
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := DB.Model(&User{}).Where("id = ?", id).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
if count == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return ErrWalletQuotaLimitExceeded
|
||||
}
|
||||
|
||||
func DecreaseUserQuota(id int, quota int, db bool) (err error) {
|
||||
|
||||
+16
-3
@@ -2,6 +2,8 @@ package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -42,11 +44,22 @@ func InitBatchUpdater() {
|
||||
func addNewRecord(type_ int, id int, value int) {
|
||||
batchUpdateLocks[type_].Lock()
|
||||
defer batchUpdateLocks[type_].Unlock()
|
||||
if _, ok := batchUpdateStores[type_][id]; !ok {
|
||||
old, ok := batchUpdateStores[type_][id]
|
||||
if !ok {
|
||||
batchUpdateStores[type_][id] = value
|
||||
} else {
|
||||
batchUpdateStores[type_][id] += value
|
||||
return
|
||||
}
|
||||
|
||||
sum := old + value
|
||||
if (value > 0 && sum < old) || (value < 0 && sum > old) {
|
||||
common.SysError(fmt.Sprintf("batch update overflow: type=%d id=%d old=%d value=%d", type_, id, old, value))
|
||||
if value > 0 {
|
||||
sum = math.MaxInt
|
||||
} else {
|
||||
sum = math.MinInt
|
||||
}
|
||||
}
|
||||
batchUpdateStores[type_][id] = sum
|
||||
}
|
||||
|
||||
func batchUpdate() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -344,9 +345,9 @@ func TestQuotaRound(t *testing.T) {
|
||||
{999.4999, 999},
|
||||
{999.5, 1000},
|
||||
{1e9 + 0.5, 1e9 + 1},
|
||||
// Oversized expression results saturate at int32 (delegated to
|
||||
// Oversized expression results saturate at the single-request limit (delegated to
|
||||
// common.QuotaRound); full saturation coverage lives in common.
|
||||
{3.6893488147419103e19, math.MaxInt32},
|
||||
{3.6893488147419103e19, common.MaxQuota},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := billingexpr.QuotaRound(tt.in)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package billingexpr_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -11,13 +10,13 @@ import (
|
||||
)
|
||||
|
||||
// TestComputeTieredQuota_ClampOnOverflow guards the billing-safety invariant
|
||||
// that an oversized tiered settlement clamps to the int32 max instead of
|
||||
// that an oversized tiered settlement clamps to the single-request max instead of
|
||||
// wrapping into a credit, and that the saturation event is surfaced on the
|
||||
// result so callers can record it for admin auditing.
|
||||
func TestComputeTieredQuota_ClampOnOverflow(t *testing.T) {
|
||||
// exprOutput = p * 1e9 = 1e18; quotaBeforeGroup = 1e18 / 1e6 * 5e5 = 5e17,
|
||||
// which far exceeds MaxInt32 and must saturate.
|
||||
exprStr := `tier("base", p * 1000000000)`
|
||||
// exprOutput = p * 1e12 = 1e21; quotaBeforeGroup = 1e21 / 1e6 * 5e5 = 5e20,
|
||||
// which far exceeds the supported single-request range and must saturate.
|
||||
exprStr := `tier("base", p * 1000000000000)`
|
||||
snap := &billingexpr.BillingSnapshot{
|
||||
BillingMode: "tiered_expr",
|
||||
ExprString: exprStr,
|
||||
@@ -29,10 +28,10 @@ func TestComputeTieredQuota_ClampOnOverflow(t *testing.T) {
|
||||
result, err := billingexpr.ComputeTieredQuota(snap, billingexpr.TokenParams{P: 1_000_000_000})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, math.MaxInt32, result.ActualQuotaAfterGroup, "oversized quota must clamp to int32 max, never wrap negative")
|
||||
assert.Equal(t, common.MaxQuota, result.ActualQuotaAfterGroup, "oversized quota must clamp, never wrap negative")
|
||||
require.NotNil(t, result.Clamp, "clamp event must be surfaced so it can be audited")
|
||||
assert.Equal(t, common.QuotaClampOverflow, result.Clamp.Kind)
|
||||
assert.Equal(t, math.MaxInt32, result.Clamp.Clamped)
|
||||
assert.Equal(t, common.MaxQuota, result.Clamp.Clamped)
|
||||
}
|
||||
|
||||
// TestComputeTieredQuota_NoClampInRange confirms an in-range settlement leaves
|
||||
|
||||
@@ -68,7 +68,7 @@ type TieredResult struct {
|
||||
MatchedTier string `json:"matched_tier"`
|
||||
RequestRules []RequestRuleTrace `json:"request_rules,omitempty"`
|
||||
CrossedTier bool `json:"crossed_tier"`
|
||||
// Clamp records an int32 saturation event during quota conversion so the
|
||||
// Clamp records a single-request saturation event during quota conversion so the
|
||||
// caller can surface it on the consume log for admin auditing. Nil when no
|
||||
// clamping occurred. Not serialized: the marker is attached separately via
|
||||
// the shared quota-saturation audit path.
|
||||
|
||||
@@ -2,11 +2,12 @@ package ollama
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
"github.com/QuantumNous/new-api/relay/channel/claude"
|
||||
"github.com/QuantumNous/new-api/relay/channel/openai"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
@@ -24,16 +25,8 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
|
||||
openaiAdaptor := openai.Adaptor{}
|
||||
openaiRequest, err := openaiAdaptor.ConvertClaudeRequest(c, info, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
openaiRequest.(*dto.GeneralOpenAIRequest).StreamOptions = &dto.StreamOptions{
|
||||
IncludeUsage: true,
|
||||
}
|
||||
// map to ollama chat request (Claude -> OpenAI -> Ollama chat)
|
||||
return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest))
|
||||
adaptor := claude.Adaptor{}
|
||||
return adaptor.ConvertClaudeRequest(c, info, request)
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
@@ -48,18 +41,37 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
if info.RelayMode == relayconstant.RelayModeEmbeddings {
|
||||
return info.ChannelBaseUrl + "/api/embed", nil
|
||||
switch info.RelayFormat {
|
||||
case types.RelayFormatClaude:
|
||||
return (&claude.Adaptor{}).GetRequestURL(info)
|
||||
default:
|
||||
switch info.RelayMode {
|
||||
case relayconstant.RelayModeEmbeddings:
|
||||
return fmt.Sprintf("%s/api/embed", info.ChannelBaseUrl), nil
|
||||
case relayconstant.RelayModeResponses:
|
||||
return fmt.Sprintf("%s/v1/responses", info.ChannelBaseUrl), nil
|
||||
case relayconstant.RelayModeResponsesCompact:
|
||||
return fmt.Sprintf("%s/v1/responses/compact", info.ChannelBaseUrl), nil
|
||||
case relayconstant.RelayModeCompletions:
|
||||
return fmt.Sprintf("%s/api/generate", info.ChannelBaseUrl), nil
|
||||
default:
|
||||
return fmt.Sprintf("%s/api/chat", info.ChannelBaseUrl), nil
|
||||
}
|
||||
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
|
||||
return info.ChannelBaseUrl + "/api/generate", nil
|
||||
}
|
||||
return info.ChannelBaseUrl + "/api/chat", nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||
channel.SetupApiRequestHeader(info, c, req)
|
||||
req.Set("Authorization", "Bearer "+info.ApiKey)
|
||||
switch info.RelayFormat {
|
||||
case types.RelayFormatClaude:
|
||||
claude.CommonClaudeHeadersOperation(c, req, info)
|
||||
anthropicVersion := c.Request.Header.Get("anthropic-version")
|
||||
if anthropicVersion == "" {
|
||||
anthropicVersion = "2023-06-01"
|
||||
}
|
||||
req.Set("anthropic-version", anthropicVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -67,12 +79,13 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
if request == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
// decide generate or chat
|
||||
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
|
||||
switch info.RelayMode {
|
||||
case relayconstant.RelayModeCompletions:
|
||||
return openAIToGenerate(c, request)
|
||||
}
|
||||
default:
|
||||
return openAIChatToOllamaChat(c, request)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
||||
return nil, nil
|
||||
@@ -83,7 +96,8 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.ConvertOpenAIResponsesRequest(c, info, request)
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||
@@ -91,9 +105,17 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||
switch info.RelayFormat {
|
||||
case types.RelayFormatClaude:
|
||||
adaptor := claude.Adaptor{}
|
||||
return adaptor.DoResponse(c, resp, info)
|
||||
default:
|
||||
switch info.RelayMode {
|
||||
case relayconstant.RelayModeEmbeddings:
|
||||
return ollamaEmbeddingHandler(c, info, resp)
|
||||
case relayconstant.RelayModeResponses, relayconstant.RelayModeResponsesCompact:
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.DoResponse(c, resp, info)
|
||||
default:
|
||||
if info.IsStream {
|
||||
return ollamaStreamHandler(c, info, resp)
|
||||
@@ -101,6 +123,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
return ollamaChatHandler(c, info, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetModelList() []string {
|
||||
return ModelList
|
||||
|
||||
@@ -68,6 +68,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil
|
||||
}
|
||||
return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil
|
||||
case relayconstant.RelayModeResponses:
|
||||
return fmt.Sprintf("%s/api/v1/responses", baseURL), nil
|
||||
default:
|
||||
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
|
||||
return fmt.Sprintf("%s/chat/completions", specialPlan.OpenAIBaseURL), nil
|
||||
@@ -102,8 +104,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
// TODO implement me
|
||||
return nil, errors.New("not implemented")
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||
|
||||
@@ -152,7 +152,7 @@ type RelayInfo struct {
|
||||
PriceData hosttypes.PriceData
|
||||
|
||||
// QuotaClamp is set (non-nil) when a quota conversion saturated at the
|
||||
// int32 bound (or NaN fallback) while computing this request's charge.
|
||||
// supported single-request bound (or NaN fallback) while computing this request's charge.
|
||||
// It is surfaced onto the consume/task log's admin_info for auditing.
|
||||
QuotaClamp *common.QuotaClamp
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestModelPriceHelperTieredRejectsPreConsumeOverflow(t *testing.T) {
|
||||
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
|
||||
"billing_setting.billing_mode": `{"tiered-overflow-model":"tiered_expr"}`,
|
||||
"billing_setting.billing_expr": `{"tiered-overflow-model":"tier(\"overflow\", p * 1000000000000000)"}`,
|
||||
"billing_setting.billing_expr": `{"tiered-overflow-model":"tier(\"overflow\", p * 100000000000000000)"}`,
|
||||
"group_ratio_setting.group_ratio": `{"default":1}`,
|
||||
}))
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@ type GeneralOpenAIRequest struct {
|
||||
SearchMode json.RawMessage `json:"search_mode,omitempty"`
|
||||
// Minimax
|
||||
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
|
||||
// vLLM
|
||||
ThinkingTokenBudget json.RawMessage `json:"thinking_token_budget,omitempty"`
|
||||
}
|
||||
|
||||
func (r GeneralOpenAIRequest) MarshalJSON() ([]byte, error) {
|
||||
|
||||
@@ -113,6 +113,29 @@ func usageFromOpenAIBillingUsage(billingUsage *dto.BillingUsage) *dto.Usage {
|
||||
if usage.TotalTokens == 0 {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
}
|
||||
if inputDetails := usage.InputTokensDetails; inputDetails != nil {
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 && inputDetails.CachedTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = inputDetails.CachedTokens
|
||||
}
|
||||
if usage.PromptTokensDetails.CachedCreationTokens == 0 && inputDetails.CachedCreationTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedCreationTokens = inputDetails.CachedCreationTokens
|
||||
}
|
||||
if usage.PromptTokensDetails.CacheWriteTokens == 0 && inputDetails.CacheWriteTokens > 0 {
|
||||
usage.PromptTokensDetails.CacheWriteTokens = inputDetails.CacheWriteTokens
|
||||
}
|
||||
if usage.PromptTokensDetails.TextTokens == 0 && inputDetails.TextTokens > 0 {
|
||||
usage.PromptTokensDetails.TextTokens = inputDetails.TextTokens
|
||||
}
|
||||
if usage.PromptTokensDetails.ImageTokens == 0 && inputDetails.ImageTokens > 0 {
|
||||
usage.PromptTokensDetails.ImageTokens = inputDetails.ImageTokens
|
||||
}
|
||||
if usage.PromptTokensDetails.AudioTokens == 0 && inputDetails.AudioTokens > 0 {
|
||||
usage.PromptTokensDetails.AudioTokens = inputDetails.AudioTokens
|
||||
}
|
||||
}
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 && usage.PromptCacheHitTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
|
||||
}
|
||||
usage.UsageSemantic = dto.BillingUsageSemanticOpenAI
|
||||
usage.UsageSource = billingUsage.Source
|
||||
usage.BillingUsage = dto.CloneBillingUsage(billingUsage)
|
||||
|
||||
+7
-3
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -272,11 +271,16 @@ func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData)
|
||||
completionTokens := float64(usage.CompletionTokens)
|
||||
promptCacheReadTokens := float64(usage.PromptTokensDetails.CachedTokens)
|
||||
|
||||
return int(math.Round((cost -
|
||||
value := (cost -
|
||||
totalPromptTokens*quotaPrice +
|
||||
promptCacheReadTokens*(quotaPrice-promptCacheReadPrice) -
|
||||
completionTokens*completionPrice) /
|
||||
(promptCacheCreatePrice - quotaPrice)))
|
||||
(promptCacheCreatePrice - quotaPrice)
|
||||
quota, clamp := common.QuotaRoundChecked(value)
|
||||
if clamp != nil {
|
||||
return -1
|
||||
}
|
||||
return quota
|
||||
}
|
||||
|
||||
func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
hosttypes "github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -42,6 +45,28 @@ func TestAttachQuotaSaturationNestsUnderAdminInfo(t *testing.T) {
|
||||
require.Equal(t, common.MaxQuota, sat["clamped"])
|
||||
}
|
||||
|
||||
func TestCalcViolationFeeQuotaSaturates(t *testing.T) {
|
||||
oldQuotaPerUnit := common.QuotaPerUnit
|
||||
common.QuotaPerUnit = 500_000
|
||||
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
|
||||
|
||||
require.Equal(t, common.MaxQuota, calcViolationFeeQuota(1e20, 1))
|
||||
}
|
||||
|
||||
func TestCalcOpenRouterCacheCreateTokensDoesNotWrap(t *testing.T) {
|
||||
oldQuotaPerUnit := common.QuotaPerUnit
|
||||
common.QuotaPerUnit = 500_000
|
||||
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
|
||||
|
||||
got := CalcOpenRouterCacheCreateTokens(dto.Usage{Cost: math.Inf(1)}, hosttypes.PriceData{
|
||||
ModelRatio: 1,
|
||||
CacheCreationRatio: 2,
|
||||
CacheRatio: 1,
|
||||
CompletionRatio: 1,
|
||||
})
|
||||
require.Equal(t, -1, got)
|
||||
}
|
||||
|
||||
// TestAttachQuotaSaturationPreservesExistingAdminInfo verifies the marker is
|
||||
// merged into a pre-existing admin_info map without clobbering it.
|
||||
func TestAttachQuotaSaturationPreservesExistingAdminInfo(t *testing.T) {
|
||||
|
||||
@@ -216,8 +216,8 @@ func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaS
|
||||
}
|
||||
|
||||
// Saturate the final sum, not just the surcharge: tieredQuota can be near
|
||||
// MaxQuota and adding the surcharge could push the total past the int32
|
||||
// quota policy bound (persisted quota columns are 32-bit).
|
||||
// MaxQuota and adding the surcharge could push the total past the
|
||||
// single-request quota policy bound.
|
||||
total, clamp := common.QuotaFromDecimalChecked(
|
||||
decimal.NewFromInt(int64(tieredQuota)).Add(summary.ToolCallSurchargeQuota),
|
||||
)
|
||||
|
||||
@@ -283,6 +283,92 @@ func TestCalculateTextQuotaSummaryUsesOpenAIBillingUsageBeforeTopLevelUsage(t *t
|
||||
require.Equal(t, 98, summary.Quota)
|
||||
}
|
||||
|
||||
func TestCalculateTextQuotaSummaryUsesOpenAIResponsesInputTokenDetails(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
OriginModelName: "gpt-4o",
|
||||
PriceData: hosttypes.PriceData{
|
||||
ModelRatio: 1,
|
||||
CompletionRatio: 2,
|
||||
CacheRatio: 0.25,
|
||||
GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1},
|
||||
},
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
responsesUsage := &dto.Usage{
|
||||
InputTokens: 100,
|
||||
OutputTokens: 10,
|
||||
TotalTokens: 110,
|
||||
InputTokensDetails: &dto.InputTokenDetails{
|
||||
CachedTokens: 40,
|
||||
},
|
||||
}
|
||||
convertedUsage := &dto.Usage{
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 10,
|
||||
TotalTokens: 110,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 40,
|
||||
},
|
||||
BillingUsage: dto.NewOpenAIResponsesBillingUsage(responsesUsage),
|
||||
}
|
||||
|
||||
effectiveUsage := effectiveBillingUsage(convertedUsage)
|
||||
require.Equal(t, 40, effectiveUsage.PromptTokensDetails.CachedTokens)
|
||||
require.Zero(t, convertedUsage.BillingUsage.OpenAIUsage.PromptTokensDetails.CachedTokens)
|
||||
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, effectiveUsage)
|
||||
require.Equal(t, 40, summary.CacheTokens)
|
||||
// 60 uncached input + 40*0.25 cached input + 10*2 output = 90.
|
||||
require.Equal(t, 90, summary.Quota)
|
||||
}
|
||||
|
||||
func TestUsageFromOpenAIBillingUsageNormalizesCacheDetailsWithoutOverwritingCanonicalValues(t *testing.T) {
|
||||
responsesUsage := &dto.Usage{
|
||||
InputTokens: 100,
|
||||
OutputTokens: 10,
|
||||
PromptCacheHitTokens: 55,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 8,
|
||||
TextTokens: 12,
|
||||
},
|
||||
InputTokensDetails: &dto.InputTokenDetails{
|
||||
CachedTokens: 40,
|
||||
CachedCreationTokens: 5,
|
||||
CacheWriteTokens: 6,
|
||||
TextTokens: 60,
|
||||
ImageTokens: 7,
|
||||
AudioTokens: 9,
|
||||
},
|
||||
}
|
||||
|
||||
billingUsage := dto.NewOpenAIResponsesBillingUsage(responsesUsage)
|
||||
usage := effectiveBillingUsage(&dto.Usage{BillingUsage: billingUsage})
|
||||
|
||||
require.Equal(t, 8, usage.PromptTokensDetails.CachedTokens)
|
||||
require.Equal(t, 5, usage.PromptTokensDetails.CachedCreationTokens)
|
||||
require.Equal(t, 6, usage.PromptTokensDetails.CacheWriteTokens)
|
||||
require.Equal(t, 12, usage.PromptTokensDetails.TextTokens)
|
||||
require.Equal(t, 7, usage.PromptTokensDetails.ImageTokens)
|
||||
require.Equal(t, 9, usage.PromptTokensDetails.AudioTokens)
|
||||
require.Zero(t, billingUsage.OpenAIUsage.PromptTokensDetails.CachedCreationTokens)
|
||||
}
|
||||
|
||||
func TestUsageFromOpenAIBillingUsageFallsBackToPromptCacheHitTokens(t *testing.T) {
|
||||
usage := effectiveBillingUsage(&dto.Usage{
|
||||
BillingUsage: dto.NewOpenAIChatBillingUsage(&dto.Usage{
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 10,
|
||||
PromptCacheHitTokens: 35,
|
||||
}),
|
||||
})
|
||||
|
||||
require.Equal(t, 35, usage.PromptTokensDetails.CachedTokens)
|
||||
}
|
||||
|
||||
func TestUsageBillingPathForLog(t *testing.T) {
|
||||
require.Equal(t, usageBillingPathAnthropic, usageBillingPathForLog(true, &dto.Usage{
|
||||
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}),
|
||||
@@ -688,9 +774,9 @@ func TestComposeTieredTextQuotaErrorFallbackUsesPreConsumedQuota(t *testing.T) {
|
||||
// settlement both saturates the quota and records the clamp on RelayInfo, so
|
||||
// every consume path (text, audio, WSS) can surface it under admin_info.
|
||||
func TestTryTieredSettleRecordsClampOnOverflow(t *testing.T) {
|
||||
// exprOutput = p * 1e9; quotaBeforeGroup = p*1e9 / 1e6 * 5e5 far exceeds
|
||||
// MaxInt32 and must saturate.
|
||||
exprStr := `tier("base", p * 1000000000)`
|
||||
// exprOutput = p * 1e12; quotaBeforeGroup = p*1e12 / 1e6 * 5e5 far exceeds
|
||||
// the supported single-request range and must saturate.
|
||||
exprStr := `tier("base", p * 1000000000000)`
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
OriginModelName: "overflow-model",
|
||||
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
|
||||
@@ -706,7 +792,7 @@ func TestTryTieredSettleRecordsClampOnOverflow(t *testing.T) {
|
||||
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, math.MaxInt32, quota, "oversized settlement must clamp, never wrap negative")
|
||||
require.Equal(t, common.MaxQuota, quota, "oversized settlement must clamp, never wrap negative")
|
||||
require.NotNil(t, relayInfo.QuotaClamp, "clamp must be recorded on RelayInfo for admin auditing")
|
||||
require.Equal(t, common.QuotaClampOverflow, relayInfo.QuotaClamp.Kind)
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ func TryTieredSettle(relayInfo *relaycommon.RelayInfo, params billingexpr.TokenP
|
||||
return true, quota, nil
|
||||
}
|
||||
|
||||
// Surface any int32 saturation from settlement onto RelayInfo so the
|
||||
// Surface any single-request saturation from settlement onto RelayInfo so the
|
||||
// consume log records it under admin_info, regardless of which caller
|
||||
// (text, audio, WSS) consumes the returned quota. First non-nil wins.
|
||||
noteQuotaClamp(relayInfo, tr.Clamp)
|
||||
|
||||
@@ -140,11 +140,11 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea
|
||||
if imageTokens > 1536 {
|
||||
imageTokens = 1536
|
||||
}
|
||||
return int(math.Round(float64(imageTokens) * multiplier)), nil
|
||||
return common.QuotaRound(float64(imageTokens) * multiplier), nil
|
||||
}
|
||||
// below cap
|
||||
imageTokens := rawPatches
|
||||
return int(math.Round(float64(imageTokens) * multiplier)), nil
|
||||
return common.QuotaRound(float64(imageTokens) * multiplier), nil
|
||||
}
|
||||
|
||||
// Tile-based calculation for 4o/4.1/4.5/o1/o3/etc.
|
||||
|
||||
@@ -88,15 +88,14 @@ func calcViolationFeeQuota(amount, groupRatio float64) int {
|
||||
if groupRatio <= 0 {
|
||||
return 0
|
||||
}
|
||||
quota := decimal.NewFromFloat(amount).
|
||||
quota := common.QuotaFromDecimal(decimal.NewFromFloat(amount).
|
||||
Mul(decimal.NewFromFloat(common.QuotaPerUnit)).
|
||||
Mul(decimal.NewFromFloat(groupRatio)).
|
||||
Round(0).
|
||||
IntPart()
|
||||
Round(0))
|
||||
if quota <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(quota)
|
||||
return quota
|
||||
}
|
||||
|
||||
// ChargeViolationFeeIfNeeded charges an additional fee after the normal flow finishes (including refund).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package operation_setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
@@ -11,12 +12,17 @@ type MonitorSetting struct {
|
||||
AutoTestChannelEnabled bool `json:"auto_test_channel_enabled"`
|
||||
AutoTestChannelMinutes float64 `json:"auto_test_channel_minutes"`
|
||||
ChannelTestMode string `json:"channel_test_mode"`
|
||||
ChannelTestConcurrency int `json:"channel_test_concurrency"`
|
||||
}
|
||||
|
||||
const (
|
||||
ChannelTestModeScheduledAll = "scheduled_all"
|
||||
ChannelTestModeAutoBanOnly = "auto_ban_only"
|
||||
ChannelTestModePassiveRecovery = "passive_recovery"
|
||||
|
||||
ChannelTestConcurrencyOptionKey = "monitor_setting.channel_test_concurrency"
|
||||
DefaultChannelTestConcurrency = 1
|
||||
MaxChannelTestConcurrency = 32
|
||||
)
|
||||
|
||||
// 默认配置
|
||||
@@ -24,6 +30,7 @@ var monitorSetting = MonitorSetting{
|
||||
AutoTestChannelEnabled: false,
|
||||
AutoTestChannelMinutes: 10,
|
||||
ChannelTestMode: ChannelTestModeScheduledAll,
|
||||
ChannelTestConcurrency: DefaultChannelTestConcurrency,
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -51,5 +58,24 @@ func GetMonitorSetting() *MonitorSetting {
|
||||
default:
|
||||
monitorSetting.ChannelTestMode = ChannelTestModeScheduledAll
|
||||
}
|
||||
monitorSetting.ChannelTestConcurrency = NormalizeChannelTestConcurrency(monitorSetting.ChannelTestConcurrency)
|
||||
return &monitorSetting
|
||||
}
|
||||
|
||||
func NormalizeChannelTestConcurrency(concurrency int) int {
|
||||
if concurrency < 1 {
|
||||
return DefaultChannelTestConcurrency
|
||||
}
|
||||
if concurrency > MaxChannelTestConcurrency {
|
||||
return MaxChannelTestConcurrency
|
||||
}
|
||||
return concurrency
|
||||
}
|
||||
|
||||
func ValidateChannelTestConcurrency(value string) error {
|
||||
concurrency, err := strconv.Atoi(value)
|
||||
if err != nil || concurrency < 1 || concurrency > MaxChannelTestConcurrency {
|
||||
return fmt.Errorf("channel test concurrency must be between 1 and %d", MaxChannelTestConcurrency)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -55,3 +55,37 @@ func TestGetMonitorSettingPreservesAutoBanOnlyMode(t *testing.T) {
|
||||
require.NotNil(t, setting)
|
||||
assert.Equal(t, ChannelTestModeAutoBanOnly, setting.ChannelTestMode)
|
||||
}
|
||||
|
||||
func TestGetMonitorSettingNormalizesChannelTestConcurrency(t *testing.T) {
|
||||
orig := monitorSetting
|
||||
t.Cleanup(func() { monitorSetting = orig })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
concurrency int
|
||||
want int
|
||||
}{
|
||||
{name: "missing uses safe default", concurrency: 0, want: DefaultChannelTestConcurrency},
|
||||
{name: "configured value is preserved", concurrency: 8, want: 8},
|
||||
{name: "oversized value is capped", concurrency: MaxChannelTestConcurrency + 1, want: MaxChannelTestConcurrency},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
monitorSetting = MonitorSetting{ChannelTestConcurrency: test.concurrency}
|
||||
|
||||
setting := GetMonitorSetting()
|
||||
|
||||
require.NotNil(t, setting)
|
||||
assert.Equal(t, test.want, setting.ChannelTestConcurrency)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChannelTestConcurrency(t *testing.T) {
|
||||
require.NoError(t, ValidateChannelTestConcurrency("1"))
|
||||
require.NoError(t, ValidateChannelTestConcurrency("32"))
|
||||
assert.Error(t, ValidateChannelTestConcurrency("0"))
|
||||
assert.Error(t, ValidateChannelTestConcurrency("33"))
|
||||
assert.Error(t, ValidateChannelTestConcurrency("1.5"))
|
||||
}
|
||||
|
||||
+15
-6
@@ -1,7 +1,6 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
@@ -9,6 +8,16 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
// maxRateLimitDurationSeconds is the largest window the count cap is computed
|
||||
// against (24h). Token-bucket capacity is count*duration; this keeps that
|
||||
// product inside int64 when the window is at most a day.
|
||||
const maxRateLimitDurationSeconds = 24 * 60 * 60
|
||||
|
||||
// maxModelRequestRateLimitCount is math.MaxInt64 / maxRateLimitDurationSeconds.
|
||||
// It is the largest count that cannot overflow int64(count)*duration for a
|
||||
// window of at most 24 hours.
|
||||
const maxModelRequestRateLimitCount int64 = math.MaxInt64 / maxRateLimitDurationSeconds
|
||||
|
||||
var ModelRequestRateLimitEnabled = false
|
||||
var ModelRequestRateLimitDurationMinutes = 1
|
||||
var ModelRequestRateLimitCount = 0
|
||||
@@ -20,7 +29,7 @@ func ModelRequestRateLimitGroup2JSONString() string {
|
||||
ModelRequestRateLimitMutex.RLock()
|
||||
defer ModelRequestRateLimitMutex.RUnlock()
|
||||
|
||||
jsonBytes, err := json.Marshal(ModelRequestRateLimitGroup)
|
||||
jsonBytes, err := common.Marshal(ModelRequestRateLimitGroup)
|
||||
if err != nil {
|
||||
common.SysLog("error marshalling model ratio: " + err.Error())
|
||||
}
|
||||
@@ -32,7 +41,7 @@ func UpdateModelRequestRateLimitGroupByJSONString(jsonStr string) error {
|
||||
defer ModelRequestRateLimitMutex.RUnlock()
|
||||
|
||||
ModelRequestRateLimitGroup = make(map[string][2]int)
|
||||
return json.Unmarshal([]byte(jsonStr), &ModelRequestRateLimitGroup)
|
||||
return common.Unmarshal([]byte(jsonStr), &ModelRequestRateLimitGroup)
|
||||
}
|
||||
|
||||
func GetGroupRateLimit(group string) (totalCount, successCount int, found bool) {
|
||||
@@ -52,7 +61,7 @@ func GetGroupRateLimit(group string) (totalCount, successCount int, found bool)
|
||||
|
||||
func CheckModelRequestRateLimitGroup(jsonStr string) error {
|
||||
checkModelRequestRateLimitGroup := make(map[string][2]int)
|
||||
err := json.Unmarshal([]byte(jsonStr), &checkModelRequestRateLimitGroup)
|
||||
err := common.Unmarshal([]byte(jsonStr), &checkModelRequestRateLimitGroup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -60,8 +69,8 @@ func CheckModelRequestRateLimitGroup(jsonStr string) error {
|
||||
if limits[0] < 0 || limits[1] < 1 {
|
||||
return fmt.Errorf("group %s has negative rate limit values: [%d, %d]", group, limits[0], limits[1])
|
||||
}
|
||||
if limits[0] > math.MaxInt32 || limits[1] > math.MaxInt32 {
|
||||
return fmt.Errorf("group %s [%d, %d] has max rate limits value 2147483647", group, limits[0], limits[1])
|
||||
if int64(limits[0]) > maxModelRequestRateLimitCount || int64(limits[1]) > maxModelRequestRateLimitCount {
|
||||
return fmt.Errorf("group %s [%d, %d] exceeds max rate limit %d", group, limits[0], limits[1], maxModelRequestRateLimitCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, test } from 'vitest'
|
||||
|
||||
import { CodeBlockEditor } from '../code-block'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
function editorTree(value: string) {
|
||||
// A fresh inline onKeyDown per call mirrors PlaygroundMessageEditor, which
|
||||
// recreates its handler on every keystroke-driven render.
|
||||
return (
|
||||
<CodeBlockEditor
|
||||
ariaLabel='Edit message'
|
||||
language='markdown'
|
||||
onChange={() => undefined}
|
||||
onKeyDown={() => undefined}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('CodeBlockEditor', () => {
|
||||
test('keeps the same editor instance when value and onKeyDown change on rerender', () => {
|
||||
const { rerender } = render(editorTree('h'))
|
||||
|
||||
const contentBefore = document.querySelector('.cm-content')
|
||||
expect(contentBefore).not.toBeNull()
|
||||
|
||||
rerender(editorTree('hi'))
|
||||
|
||||
const contentAfter = document.querySelector('.cm-content')
|
||||
// If the EditorView were torn down and rebuilt, the content node would be
|
||||
// replaced and the cursor would reset to the document start, making typed
|
||||
// characters pile up at the beginning (text appears right-to-left).
|
||||
expect(contentAfter).toBe(contentBefore)
|
||||
expect(contentAfter?.textContent).toContain('hi')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { Response } from '../response'
|
||||
import {
|
||||
FADE_DURATION_MS,
|
||||
FADE_HYDRATION_THRESHOLD,
|
||||
FADE_STAGGER_MAX_MS,
|
||||
} from '../response-fade'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Response streaming fade', () => {
|
||||
test('wraps newly streamed words when final is false', () => {
|
||||
const { rerender } = render(<Response final={false}>Hello</Response>)
|
||||
|
||||
expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
|
||||
0
|
||||
)
|
||||
expect(screen.getByText('Hello')).toBeTruthy()
|
||||
|
||||
rerender(<Response final={false}>Hello world</Response>)
|
||||
|
||||
const fades = [...document.querySelectorAll('[data-stream-fade]')]
|
||||
expect(fades.some((node) => node.textContent === 'world')).toBe(true)
|
||||
})
|
||||
|
||||
test('renders settled content with zero fade wrappers when final is true', () => {
|
||||
render(<Response final>Hello world</Response>)
|
||||
|
||||
expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
|
||||
expect(screen.getByText(/Hello world/)).toBeTruthy()
|
||||
})
|
||||
|
||||
test('does not fade inline code or fenced code blocks', () => {
|
||||
render(
|
||||
<Response final={false}>
|
||||
{['Use `code` and:', '', '```', 'block', '```'].join('\n')}
|
||||
</Response>
|
||||
)
|
||||
|
||||
const fades = [...document.querySelectorAll('[data-stream-fade]')]
|
||||
const fadedText = fades.map((node) => node.textContent ?? '').join('')
|
||||
expect(fadedText.includes('block')).toBe(false)
|
||||
expect(
|
||||
fades.every((node) => {
|
||||
const text = node.textContent ?? ''
|
||||
return text.trim() !== 'code' && text.trim() !== 'block'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('does not re-animate words after markdown restructuring around strong', () => {
|
||||
vi.spyOn(performance, 'now').mockReturnValue(1000)
|
||||
const { rerender } = render(<Response final={false}>**fin</Response>)
|
||||
expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
|
||||
0
|
||||
)
|
||||
|
||||
vi.spyOn(performance, 'now').mockReturnValue(
|
||||
1000 + FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1
|
||||
)
|
||||
rerender(<Response final={false}>**final**</Response>)
|
||||
|
||||
const strong = document.querySelector('strong')
|
||||
expect(strong?.textContent).toContain('final')
|
||||
|
||||
const fades = [...document.querySelectorAll('[data-stream-fade]')]
|
||||
expect(
|
||||
fades.every((node) => !(node.textContent ?? '').includes('final'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('suppresses fades on the first streaming render of hydrated content', () => {
|
||||
const hydrated = 'word '.repeat(FADE_HYDRATION_THRESHOLD)
|
||||
|
||||
render(<Response final={false}>{hydrated}</Response>)
|
||||
|
||||
expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('drops all fade wrappers once the stream settles', () => {
|
||||
const { rerender } = render(
|
||||
<Response final={false}>Streaming text</Response>
|
||||
)
|
||||
expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
|
||||
0
|
||||
)
|
||||
|
||||
rerender(<Response final>Streaming text</Response>)
|
||||
expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
beginRun,
|
||||
classifyValue,
|
||||
createFadeState,
|
||||
endRun,
|
||||
FADE_DURATION_MS,
|
||||
FADE_HYDRATION_THRESHOLD,
|
||||
FADE_STAGGER_MAX_MS,
|
||||
FADE_STAGGER_MS,
|
||||
splitWords,
|
||||
stageRun,
|
||||
} from '../response-fade'
|
||||
|
||||
describe('splitWords', () => {
|
||||
test('round-trips ASCII words with trailing whitespace', () => {
|
||||
const value = 'Hello world, stream.\n'
|
||||
expect(splitWords(value).join('')).toBe(value)
|
||||
})
|
||||
|
||||
test('keeps leading whitespace as its own part', () => {
|
||||
expect(splitWords(' hi')).toEqual([' ', 'hi'])
|
||||
})
|
||||
|
||||
test('segments CJK without spaces via Intl.Segmenter', () => {
|
||||
const value = '你好世界'
|
||||
const parts = splitWords(value)
|
||||
expect(parts.join('')).toBe(value)
|
||||
expect(parts.length).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyValue', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(performance, 'now').mockReturnValue(1000)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('animates only newly appended words with capped stagger', () => {
|
||||
const state = createFadeState()
|
||||
const first = beginRun(state)
|
||||
const firstSegments = classifyValue(first, 'one two ')
|
||||
endRun(first)
|
||||
|
||||
expect(firstSegments.filter((s) => s.animated)).toHaveLength(2)
|
||||
expect(firstSegments[0]?.delay).toBe(0)
|
||||
expect(firstSegments[1]?.delay).toBe(FADE_STAGGER_MS)
|
||||
|
||||
vi.spyOn(performance, 'now').mockReturnValue(
|
||||
1000 + FADE_DURATION_MS + FADE_STAGGER_MS + 1
|
||||
)
|
||||
const second = beginRun(state)
|
||||
const secondSegments = classifyValue(second, 'one two three four')
|
||||
endRun(second)
|
||||
|
||||
const animated = secondSegments.filter((s) => s.animated)
|
||||
expect(animated.map((s) => s.value.trim())).toEqual(['three', 'four'])
|
||||
expect(animated[0]?.delay).toBe(0)
|
||||
expect(animated[1]?.delay).toBe(FADE_STAGGER_MS)
|
||||
})
|
||||
|
||||
test('caps stagger delay at FADE_STAGGER_MAX_MS', () => {
|
||||
const state = createFadeState()
|
||||
const run = beginRun(state)
|
||||
const words = Array.from({ length: 20 }, (_, i) => `w${i}`).join(' ')
|
||||
const segments = classifyValue(run, words)
|
||||
endRun(run)
|
||||
|
||||
const delays = segments.filter((s) => s.animated).map((s) => s.delay)
|
||||
expect(Math.max(...delays)).toBe(FADE_STAGGER_MAX_MS)
|
||||
})
|
||||
|
||||
test('replays identical delay while still inside the animation window', () => {
|
||||
const state = createFadeState()
|
||||
const first = beginRun(state)
|
||||
classifyValue(first, 'hello ')
|
||||
endRun(first)
|
||||
|
||||
vi.spyOn(performance, 'now').mockReturnValue(1000 + FADE_DURATION_MS / 2)
|
||||
const second = beginRun(state)
|
||||
const segments = classifyValue(second, 'hello world')
|
||||
endRun(second)
|
||||
|
||||
expect(segments[0]).toMatchObject({
|
||||
animated: true,
|
||||
delay: 0,
|
||||
start: 0,
|
||||
value: 'hello ',
|
||||
})
|
||||
expect(segments[1]).toMatchObject({
|
||||
animated: true,
|
||||
start: 6,
|
||||
value: 'world',
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps the same start offset when the head word grows', () => {
|
||||
const state = createFadeState()
|
||||
const first = beginRun(state)
|
||||
const head = classifyValue(first, 'hel')
|
||||
endRun(first)
|
||||
expect(head[0]?.start).toBe(0)
|
||||
|
||||
vi.spyOn(performance, 'now').mockReturnValue(1050)
|
||||
const second = beginRun(state)
|
||||
const grown = classifyValue(second, 'hello')
|
||||
endRun(second)
|
||||
|
||||
expect(grown[0]?.start).toBe(0)
|
||||
expect(grown[0]?.animated).toBe(true)
|
||||
expect(grown[0]?.value).toBe('hello')
|
||||
})
|
||||
|
||||
test('does not animate whitespace-only parts', () => {
|
||||
const state = createFadeState()
|
||||
const run = beginRun(state)
|
||||
const segments = classifyValue(run, ' \n')
|
||||
endRun(run)
|
||||
|
||||
expect(segments.every((s) => !s.animated)).toBe(true)
|
||||
})
|
||||
|
||||
test('suppresses animation on the hydration baseline', () => {
|
||||
const state = createFadeState()
|
||||
const longText = 'a'.repeat(FADE_HYDRATION_THRESHOLD + 1)
|
||||
const run = beginRun(state, true)
|
||||
const segments = classifyValue(run, longText)
|
||||
endRun(run)
|
||||
|
||||
expect(segments.every((s) => !s.animated)).toBe(true)
|
||||
expect(state.prevCount).toBe(longText.length)
|
||||
})
|
||||
|
||||
test('stops replaying animation after the window expires', () => {
|
||||
const state = createFadeState()
|
||||
const first = beginRun(state)
|
||||
classifyValue(first, 'done ')
|
||||
endRun(first)
|
||||
|
||||
vi.spyOn(performance, 'now').mockReturnValue(
|
||||
1000 + FADE_DURATION_MS + 1
|
||||
)
|
||||
const second = beginRun(state)
|
||||
const segments = classifyValue(second, 'done next')
|
||||
endRun(second)
|
||||
|
||||
expect(segments[0]).toMatchObject({ animated: false, value: 'done ' })
|
||||
expect(segments[1]).toMatchObject({ animated: true, value: 'next' })
|
||||
expect(state.active.has(0)).toBe(false)
|
||||
})
|
||||
|
||||
test('abandoned staged runs leave committed state untouched', () => {
|
||||
const state = createFadeState()
|
||||
const first = beginRun(state)
|
||||
classifyValue(first, 'keep ')
|
||||
endRun(first)
|
||||
expect(state.prevCount).toBe(5)
|
||||
|
||||
const abandoned = beginRun(state)
|
||||
classifyValue(abandoned, 'keep extra')
|
||||
stageRun(abandoned)
|
||||
// Never commit — simulate React discarding the render
|
||||
state.pending = null
|
||||
|
||||
expect(state.prevCount).toBe(5)
|
||||
expect(state.active.size).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -265,7 +265,7 @@ function getCodeBlockMaxHeight(
|
||||
|
||||
function getCodeMirrorExtensions(options: {
|
||||
language: BundledLanguage | string
|
||||
onKeyDown?: (event: globalThis.KeyboardEvent) => void
|
||||
onKeyDown: (event: globalThis.KeyboardEvent) => void
|
||||
readOnly: boolean
|
||||
showLineNumbers: boolean
|
||||
}): Extension[] {
|
||||
@@ -276,23 +276,18 @@ function getCodeMirrorExtensions(options: {
|
||||
EditorState.tabSize.of(2),
|
||||
EditorState.readOnly.of(options.readOnly),
|
||||
EditorView.editable.of(!options.readOnly),
|
||||
EditorView.domEventHandlers({
|
||||
keydown(event) {
|
||||
options.onKeyDown(event)
|
||||
return event.defaultPrevented
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
if (options.showLineNumbers) {
|
||||
extensions.unshift(lineNumbers())
|
||||
}
|
||||
|
||||
if (options.onKeyDown) {
|
||||
extensions.push(
|
||||
EditorView.domEventHandlers({
|
||||
keydown(event) {
|
||||
options.onKeyDown?.(event)
|
||||
return event.defaultPrevented
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return extensions
|
||||
}
|
||||
|
||||
@@ -311,21 +306,27 @@ function CodeMirrorCodeView({
|
||||
const editorViewRef = useRef<EditorView | null>(null)
|
||||
const initialValueRef = useRef(value)
|
||||
const onChangeRef = useRef(onChange)
|
||||
const onKeyDownRef = useRef(onKeyDown)
|
||||
const editorMinHeight = `${Math.max(4, rows) * 1.5 + 2}rem`
|
||||
// onKeyDown is delivered through a ref so a new handler identity from the
|
||||
// parent (recreated on every keystroke-driven render) does not invalidate
|
||||
// the extensions and tear down the EditorView, which would reset the cursor
|
||||
// to the document start and make typing appear right-to-left.
|
||||
const editorExtensions = useMemo(
|
||||
() =>
|
||||
getCodeMirrorExtensions({
|
||||
language,
|
||||
onKeyDown,
|
||||
onKeyDown: (event) => onKeyDownRef.current?.(event),
|
||||
readOnly,
|
||||
showLineNumbers,
|
||||
}),
|
||||
[language, onKeyDown, readOnly, showLineNumbers]
|
||||
[language, readOnly, showLineNumbers]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
onKeyDownRef.current = onKeyDown
|
||||
}, [onChange, onKeyDown])
|
||||
|
||||
useEffect(() => {
|
||||
const editorHost = editorHostRef.current
|
||||
@@ -357,6 +358,10 @@ function CodeMirrorCodeView({
|
||||
}, [autoFocus, editorExtensions])
|
||||
|
||||
useEffect(() => {
|
||||
// Track the latest value so a future editor rebuild (e.g. language change)
|
||||
// starts from the current document instead of the mount-time snapshot.
|
||||
initialValueRef.current = value
|
||||
|
||||
const editorView = editorViewRef.current
|
||||
if (!editorView) {
|
||||
return
|
||||
|
||||
@@ -191,7 +191,10 @@ export type ReasoningContentProps = ComponentProps<
|
||||
}
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
({ className, children, ...props }: ReasoningContentProps) => {
|
||||
const { isStreaming } = useReasoning()
|
||||
|
||||
return (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'CollapsibleContent group/reasoning-content border-border/70 mt-2 ml-1.5 border-l pl-3 text-sm leading-5',
|
||||
@@ -201,12 +204,17 @@ export const ReasoningContent = memo(
|
||||
{...props}
|
||||
>
|
||||
<div className='transition-[opacity,transform] duration-200 ease-out group-data-[closed]/reasoning-content:-translate-y-1 group-data-[closed]/reasoning-content:opacity-0 group-data-[open]/reasoning-content:translate-y-0 group-data-[open]/reasoning-content:opacity-100 motion-reduce:transition-none'>
|
||||
<Response className='grid gap-1.5 [&_li]:my-0.5 [&_ol]:my-1.5 [&_p]:my-1.5 [&_p]:leading-5 [&_ul]:my-1.5'>
|
||||
<Response
|
||||
className='grid gap-1.5 [&_li]:my-0.5 [&_ol]:my-1.5 [&_p]:my-1.5 [&_p]:leading-5 [&_ul]:my-1.5'
|
||||
final={!isStreaming}
|
||||
parserId='new-api-reasoning'
|
||||
>
|
||||
{children}
|
||||
</Response>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Reasoning.displayName = 'Reasoning'
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
/** Must match the animation duration on `[data-stream-fade]` in styles/index.css */
|
||||
export const FADE_DURATION_MS = 250
|
||||
export const FADE_STAGGER_MS = 25
|
||||
export const FADE_STAGGER_MAX_MS = 250
|
||||
/**
|
||||
* Text already longer than this when animation starts is hydrated/resumed
|
||||
* content (reconnected stream, conversation switch), not a fresh delta —
|
||||
* that content becomes the baseline instead of re-fading.
|
||||
*/
|
||||
export const FADE_HYDRATION_THRESHOLD = 120
|
||||
|
||||
type FadeEntry = { at: number; delay: number }
|
||||
|
||||
type PendingRun = {
|
||||
prevCount: number
|
||||
additions: Map<number, FadeEntry>
|
||||
now: number
|
||||
}
|
||||
|
||||
export type FadeState = {
|
||||
/** Total characters classified during the last committed run */
|
||||
prevCount: number
|
||||
/** Parts still mid-animation, keyed by start offset */
|
||||
active: Map<number, FadeEntry>
|
||||
/** True until the first run commits */
|
||||
firstRun: boolean
|
||||
/** Staged result of the latest render; published on commit */
|
||||
pending: PendingRun | null
|
||||
}
|
||||
|
||||
export type FadeRun = {
|
||||
state: FadeState
|
||||
now: number
|
||||
count: number
|
||||
newIndex: number
|
||||
/** Baseline mode: classify everything as already seen */
|
||||
suppress: boolean
|
||||
additions: Map<number, FadeEntry>
|
||||
}
|
||||
|
||||
export type FadeSegment = {
|
||||
start: number
|
||||
value: string
|
||||
animated: boolean
|
||||
delay: number
|
||||
}
|
||||
|
||||
const WORD_REGEX = /\S+\s*/g
|
||||
const NON_WHITESPACE_REGEX = /\S/
|
||||
/**
|
||||
* Scripts without word-delimiting spaces: Thai, Lao, Myanmar, Khmer,
|
||||
* Tibetan, CJK ideographs/kana, Hangul, and CJK compatibility ideographs.
|
||||
*/
|
||||
const SPACELESS_REGEX =
|
||||
/[\u0E00-\u0EFF\u0F00-\u0FFF\u1000-\u109F\u1780-\u17FF\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/
|
||||
|
||||
let wordSegmenter: Intl.Segmenter | null | undefined
|
||||
|
||||
function getWordSegmenter(): Intl.Segmenter | null {
|
||||
if (wordSegmenter === undefined) {
|
||||
wordSegmenter =
|
||||
typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
|
||||
? new Intl.Segmenter(undefined, { granularity: 'word' })
|
||||
: null
|
||||
}
|
||||
return wordSegmenter
|
||||
}
|
||||
|
||||
function pushSegmentedParts(parts: string[], token: string): void {
|
||||
const segmenter = getWordSegmenter()
|
||||
if (segmenter == null) {
|
||||
parts.push(token)
|
||||
return
|
||||
}
|
||||
const trailing = /\s+$/.exec(token)
|
||||
const word = trailing == null ? token : token.slice(0, trailing.index)
|
||||
for (const segment of segmenter.segment(word)) {
|
||||
parts.push(segment.segment)
|
||||
}
|
||||
if (trailing != null) {
|
||||
parts.push(trailing[0])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits text into word parts (non-whitespace run plus trailing whitespace).
|
||||
* Spaceless scripts are further split via Intl.Segmenter.
|
||||
* Concatenating the result always reproduces the input exactly.
|
||||
*/
|
||||
export function splitWords(value: string): string[] {
|
||||
const parts: string[] = []
|
||||
WORD_REGEX.lastIndex = 0
|
||||
let index = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = WORD_REGEX.exec(value)) !== null) {
|
||||
if (match.index > index) {
|
||||
parts.push(value.slice(index, match.index))
|
||||
}
|
||||
const token = match[0]
|
||||
if (SPACELESS_REGEX.test(token)) {
|
||||
pushSegmentedParts(parts, token)
|
||||
} else {
|
||||
parts.push(token)
|
||||
}
|
||||
index = match.index + token.length
|
||||
}
|
||||
if (index < value.length) {
|
||||
parts.push(value.slice(index))
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
export function createFadeState(): FadeState {
|
||||
return { prevCount: 0, active: new Map(), firstRun: true, pending: null }
|
||||
}
|
||||
|
||||
export function beginRun(state: FadeState, suppress = false): FadeRun {
|
||||
return {
|
||||
state,
|
||||
now: performance.now(),
|
||||
count: 0,
|
||||
newIndex: 0,
|
||||
suppress,
|
||||
additions: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stages the run's result without publishing. Classification never mutates
|
||||
* committed state during render, so abandoned renders leave no trace.
|
||||
*/
|
||||
export function stageRun(run: FadeRun): void {
|
||||
run.state.pending = {
|
||||
prevCount: run.count,
|
||||
additions: run.additions,
|
||||
now: run.now,
|
||||
}
|
||||
}
|
||||
|
||||
/** Publishes the staged run: baseline offset, new animations, pruned entries. */
|
||||
export function commitRun(state: FadeState): void {
|
||||
const pending = state.pending
|
||||
if (pending == null) {
|
||||
return
|
||||
}
|
||||
state.pending = null
|
||||
state.firstRun = false
|
||||
state.prevCount = pending.prevCount
|
||||
for (const [start, entry] of pending.additions) {
|
||||
state.active.set(start, entry)
|
||||
}
|
||||
for (const [start, entry] of state.active) {
|
||||
if (pending.now - entry.at >= entry.delay + FADE_DURATION_MS) {
|
||||
state.active.delete(start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stages and immediately commits — for callers without a commit phase. */
|
||||
export function endRun(run: FadeRun): void {
|
||||
stageRun(run)
|
||||
commitRun(run.state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies one text value into fade segments, advancing document-order
|
||||
* character offset. New parts (start >= prevCount) animate; parts still
|
||||
* inside their animation window replay identical props.
|
||||
*/
|
||||
export function classifyValue(run: FadeRun, value: string): FadeSegment[] {
|
||||
const { state, now } = run
|
||||
const segments: FadeSegment[] = []
|
||||
for (const part of splitWords(value)) {
|
||||
const start = run.count
|
||||
run.count += part.length
|
||||
if (run.suppress || !NON_WHITESPACE_REGEX.test(part)) {
|
||||
segments.push({ start, value: part, animated: false, delay: 0 })
|
||||
continue
|
||||
}
|
||||
if (start >= state.prevCount) {
|
||||
const staged = run.additions.get(start)
|
||||
const delay =
|
||||
staged?.delay ??
|
||||
Math.min(run.newIndex * FADE_STAGGER_MS, FADE_STAGGER_MAX_MS)
|
||||
run.newIndex += 1
|
||||
run.additions.set(start, staged ?? { at: now, delay })
|
||||
segments.push({ start, value: part, animated: true, delay })
|
||||
continue
|
||||
}
|
||||
const entry = state.active.get(start)
|
||||
if (entry != null && now - entry.at < entry.delay + FADE_DURATION_MS) {
|
||||
segments.push({
|
||||
start,
|
||||
value: part,
|
||||
animated: true,
|
||||
delay: entry.delay,
|
||||
})
|
||||
continue
|
||||
}
|
||||
segments.push({ start, value: part, animated: false, delay: 0 })
|
||||
}
|
||||
return segments
|
||||
}
|
||||
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { Fragment, type CSSProperties, type ReactNode } from 'react'
|
||||
import {
|
||||
shouldOpenLinkInNewTab,
|
||||
type ImageNode,
|
||||
@@ -24,13 +24,45 @@ import {
|
||||
type TextNode,
|
||||
} from 'stream-markdown-parser'
|
||||
|
||||
import { classifyValue, type FadeRun } from './response-fade'
|
||||
import { ResponseImage } from './response-renderer-image'
|
||||
import type { RenderChildren } from './response-types'
|
||||
|
||||
export function renderTextNode(node: TextNode): ReactNode {
|
||||
const STREAM_FADE_DELAY_VAR = '--stream-fade-delay'
|
||||
|
||||
export function renderTextNode(
|
||||
node: TextNode,
|
||||
fadeRun?: FadeRun
|
||||
): ReactNode {
|
||||
if (!fadeRun) {
|
||||
return node.content
|
||||
}
|
||||
|
||||
const segments = classifyValue(fadeRun, node.content)
|
||||
if (segments.every((segment) => !segment.animated)) {
|
||||
return node.content
|
||||
}
|
||||
|
||||
return segments.map((segment) => {
|
||||
if (!segment.animated) {
|
||||
return <Fragment key={segment.start}>{segment.value}</Fragment>
|
||||
}
|
||||
|
||||
const style =
|
||||
segment.delay > 0
|
||||
? ({
|
||||
[STREAM_FADE_DELAY_VAR]: `${segment.delay}ms`,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<span data-stream-fade='' key={segment.start} style={style}>
|
||||
{segment.value}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function renderLink(
|
||||
node: LinkNode,
|
||||
key: string,
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { ReactNode } from 'react'
|
||||
import type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
|
||||
|
||||
import { getNodeKey } from './response-content'
|
||||
import type { FadeRun } from './response-fade'
|
||||
import {
|
||||
hasParsedChildren,
|
||||
isBlockquoteNode,
|
||||
@@ -52,40 +53,68 @@ import {
|
||||
renderTextNode,
|
||||
} from './response-renderer-inline'
|
||||
import { renderTable } from './response-renderer-table'
|
||||
import type { BlockRendererOptions, RenderChildren } from './response-types'
|
||||
|
||||
export function renderChildren(nodes: ParsedNode[]): ReactNode {
|
||||
return nodes.map((node, index) => renderNode(node, getNodeKey(node, index)))
|
||||
function createRenderChildren(fadeRun?: FadeRun): RenderChildren {
|
||||
return (nodes) => renderChildren(nodes, fadeRun)
|
||||
}
|
||||
|
||||
export function renderFootnotes(footnotes: FootnoteNode[]): ReactNode {
|
||||
return renderFootnotesBlock(footnotes, { renderChildren })
|
||||
export function renderChildren(
|
||||
nodes: ParsedNode[],
|
||||
fadeRun?: FadeRun
|
||||
): ReactNode {
|
||||
const options: BlockRendererOptions = {
|
||||
fadeRun,
|
||||
renderChildren: createRenderChildren(fadeRun),
|
||||
}
|
||||
return nodes.map((node, index) =>
|
||||
renderNode(node, getNodeKey(node, index), options)
|
||||
)
|
||||
}
|
||||
|
||||
function renderNode(node: ParsedNode, key: string): ReactNode {
|
||||
export function renderFootnotes(
|
||||
footnotes: FootnoteNode[],
|
||||
fadeRun?: FadeRun
|
||||
): ReactNode {
|
||||
return renderFootnotesBlock(footnotes, {
|
||||
fadeRun,
|
||||
renderChildren: createRenderChildren(fadeRun),
|
||||
})
|
||||
}
|
||||
|
||||
/** Settled (non-animated) renderChildren for skipped subtrees */
|
||||
const settledRenderChildren = createRenderChildren()
|
||||
|
||||
function renderNode(
|
||||
node: ParsedNode,
|
||||
key: string,
|
||||
options: BlockRendererOptions
|
||||
): ReactNode {
|
||||
if (isTextNode(node)) {
|
||||
return renderTextNode(node)
|
||||
return renderTextNode(node, options.fadeRun)
|
||||
}
|
||||
|
||||
if (isHeadingNode(node)) {
|
||||
return renderHeading(node, key, { renderChildren })
|
||||
return renderHeading(node, key, options)
|
||||
}
|
||||
|
||||
if (node.type === 'paragraph' && hasParsedChildren(node)) {
|
||||
return (
|
||||
<p className='my-3 leading-7' key={key}>
|
||||
{renderChildren(node.children)}
|
||||
{options.renderChildren(node.children)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'inline' && hasParsedChildren(node)) {
|
||||
return <span key={key}>{renderChildren(node.children)}</span>
|
||||
return <span key={key}>{options.renderChildren(node.children)}</span>
|
||||
}
|
||||
|
||||
if (isListNode(node)) {
|
||||
return renderList(node, key, { renderChildren })
|
||||
return renderList(node, key, options)
|
||||
}
|
||||
|
||||
// Skip list: code / math / html / image — no fade wrapping, offset not advanced
|
||||
if (isCodeBlockNode(node)) {
|
||||
return renderCodeBlock(node, key)
|
||||
}
|
||||
@@ -102,7 +131,7 @@ function renderNode(node: ParsedNode, key: string): ReactNode {
|
||||
}
|
||||
|
||||
if (isLinkNode(node)) {
|
||||
return renderLink(node, key, renderChildren)
|
||||
return renderLink(node, key, options.renderChildren)
|
||||
}
|
||||
|
||||
if (isImageNode(node)) {
|
||||
@@ -110,47 +139,47 @@ function renderNode(node: ParsedNode, key: string): ReactNode {
|
||||
}
|
||||
|
||||
if (isBlockquoteNode(node)) {
|
||||
return renderBlockquote(node, key, { renderChildren })
|
||||
return renderBlockquote(node, key, options)
|
||||
}
|
||||
|
||||
if (isTableNode(node)) {
|
||||
return renderTable(node, key, { renderChildren })
|
||||
return renderTable(node, key, options)
|
||||
}
|
||||
|
||||
if (isDefinitionListNode(node)) {
|
||||
return renderDefinitionList(node, key, { renderChildren })
|
||||
return renderDefinitionList(node, key, options)
|
||||
}
|
||||
|
||||
if (node.type === 'strong' && hasParsedChildren(node)) {
|
||||
return (
|
||||
<strong className='text-foreground font-semibold' key={key}>
|
||||
{renderChildren(node.children)}
|
||||
{options.renderChildren(node.children)}
|
||||
</strong>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'emphasis' && hasParsedChildren(node)) {
|
||||
return <em key={key}>{renderChildren(node.children)}</em>
|
||||
return <em key={key}>{options.renderChildren(node.children)}</em>
|
||||
}
|
||||
|
||||
if (node.type === 'strikethrough' && hasParsedChildren(node)) {
|
||||
return <del key={key}>{renderChildren(node.children)}</del>
|
||||
return <del key={key}>{options.renderChildren(node.children)}</del>
|
||||
}
|
||||
|
||||
if (node.type === 'highlight' && hasParsedChildren(node)) {
|
||||
return <mark key={key}>{renderChildren(node.children)}</mark>
|
||||
return <mark key={key}>{options.renderChildren(node.children)}</mark>
|
||||
}
|
||||
|
||||
if (node.type === 'insert' && hasParsedChildren(node)) {
|
||||
return <ins key={key}>{renderChildren(node.children)}</ins>
|
||||
return <ins key={key}>{options.renderChildren(node.children)}</ins>
|
||||
}
|
||||
|
||||
if (node.type === 'subscript' && hasParsedChildren(node)) {
|
||||
return <sub key={key}>{renderChildren(node.children)}</sub>
|
||||
return <sub key={key}>{options.renderChildren(node.children)}</sub>
|
||||
}
|
||||
|
||||
if (node.type === 'superscript' && hasParsedChildren(node)) {
|
||||
return <sup key={key}>{renderChildren(node.children)}</sup>
|
||||
return <sup key={key}>{options.renderChildren(node.children)}</sup>
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -204,7 +233,9 @@ function renderNode(node: ParsedNode, key: string): ReactNode {
|
||||
}
|
||||
|
||||
if (isHtmlBlockNode(node) && node.tag === 'details') {
|
||||
return renderDetails(node, key, { renderChildren })
|
||||
return renderDetails(node, key, {
|
||||
renderChildren: settledRenderChildren,
|
||||
})
|
||||
}
|
||||
|
||||
if (node.type === 'html_block' && 'content' in node) {
|
||||
@@ -216,7 +247,7 @@ function renderNode(node: ParsedNode, key: string): ReactNode {
|
||||
}
|
||||
|
||||
if (hasParsedChildren(node)) {
|
||||
return <span key={key}>{renderChildren(node.children)}</span>
|
||||
return <span key={key}>{options.renderChildren(node.children)}</span>
|
||||
}
|
||||
|
||||
if ('content' in node && typeof node.content === 'string') {
|
||||
|
||||
@@ -19,10 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import type { ReactNode } from 'react'
|
||||
import type { FootnoteNode, ParsedNode } from 'stream-markdown-parser'
|
||||
|
||||
import type { FadeRun } from './response-fade'
|
||||
|
||||
export type ResponseProps = {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
final?: boolean
|
||||
/** Distinct stream-markdown-parser cache id when multiple Responses stream concurrently */
|
||||
parserId?: string
|
||||
}
|
||||
|
||||
export type AlertKind = 'note' | 'tip' | 'important' | 'warning' | 'caution'
|
||||
@@ -42,4 +46,5 @@ export type RenderChildren = (nodes: ParsedNode[]) => ReactNode
|
||||
|
||||
export type BlockRendererOptions = {
|
||||
renderChildren: RenderChildren
|
||||
fadeRun?: FadeRun
|
||||
}
|
||||
|
||||
@@ -18,37 +18,96 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import { memo, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { getMarkdown, parseMarkdownToStructure } from 'stream-markdown-parser'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getMarkdownContent, parseResponseContent } from './response-content'
|
||||
import {
|
||||
beginRun,
|
||||
commitRun,
|
||||
createFadeState,
|
||||
FADE_HYDRATION_THRESHOLD,
|
||||
stageRun,
|
||||
type FadeRun,
|
||||
type FadeState,
|
||||
} from './response-fade'
|
||||
import { renderChildren, renderFootnotes } from './response-renderer'
|
||||
import type { ResponseProps } from './response-types'
|
||||
|
||||
const markdown = getMarkdown('new-api-response')
|
||||
const DEFAULT_PARSER_ID = 'new-api-response'
|
||||
const MAX_PARSED_MARKDOWN_CHARS = 20_000
|
||||
type MarkdownInstance = ReturnType<typeof getMarkdown>
|
||||
|
||||
const markdownByParserId = new Map<string, MarkdownInstance>()
|
||||
|
||||
function getCachedMarkdown(parserId: string): MarkdownInstance {
|
||||
const cached = markdownByParserId.get(parserId)
|
||||
if (cached != null) {
|
||||
return cached
|
||||
}
|
||||
const markdown = getMarkdown(parserId)
|
||||
markdownByParserId.set(parserId, markdown)
|
||||
return markdown
|
||||
}
|
||||
|
||||
export const Response = memo((props: ResponseProps) => {
|
||||
const content = getMarkdownContent(props.children)
|
||||
const isFinal = props.final ?? true
|
||||
const shouldAnimate = !isFinal
|
||||
const parserId = props.parserId ?? DEFAULT_PARSER_ID
|
||||
const markdown = getCachedMarkdown(parserId)
|
||||
const shouldParseMarkdown = content.length <= MAX_PARSED_MARKDOWN_CHARS
|
||||
const fadeStateRef = useRef<FadeState | null>(null)
|
||||
if (fadeStateRef.current == null) {
|
||||
fadeStateRef.current = createFadeState()
|
||||
}
|
||||
|
||||
const nodes = useMemo(() => {
|
||||
if (!shouldParseMarkdown) {
|
||||
return []
|
||||
}
|
||||
|
||||
return parseMarkdownToStructure(content, markdown, {
|
||||
final: props.final ?? true,
|
||||
final: isFinal,
|
||||
validateLink: markdown.options.validateLink,
|
||||
})
|
||||
}, [content, props.final, shouldParseMarkdown])
|
||||
}, [content, isFinal, markdown, shouldParseMarkdown])
|
||||
const parsedContent = useMemo(() => parseResponseContent(nodes), [nodes])
|
||||
const renderedContent =
|
||||
parsedContent.bodyNodes.length > 0
|
||||
? renderChildren(parsedContent.bodyNodes)
|
||||
: content
|
||||
const footnotes = renderFootnotes(parsedContent.footnotes)
|
||||
|
||||
let fadeRun: FadeRun | undefined
|
||||
let renderedContent
|
||||
let footnotes
|
||||
|
||||
if (parsedContent.bodyNodes.length > 0) {
|
||||
if (shouldAnimate) {
|
||||
const fadeState = fadeStateRef.current
|
||||
const suppress =
|
||||
fadeState.firstRun && content.length > FADE_HYDRATION_THRESHOLD
|
||||
fadeRun = beginRun(fadeState, suppress)
|
||||
renderedContent = renderChildren(parsedContent.bodyNodes, fadeRun)
|
||||
footnotes = renderFootnotes(parsedContent.footnotes, fadeRun)
|
||||
stageRun(fadeRun)
|
||||
} else {
|
||||
renderedContent = renderChildren(parsedContent.bodyNodes)
|
||||
footnotes = renderFootnotes(parsedContent.footnotes)
|
||||
}
|
||||
} else {
|
||||
renderedContent = content
|
||||
footnotes = renderFootnotes(parsedContent.footnotes)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldAnimate) {
|
||||
return
|
||||
}
|
||||
const fadeState = fadeStateRef.current
|
||||
if (fadeState == null) {
|
||||
return
|
||||
}
|
||||
commitRun(fadeState)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -335,6 +335,7 @@ export function ModelMutateDrawer({
|
||||
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
|
||||
'monitor_setting.auto_test_channel_enabled': false,
|
||||
'monitor_setting.auto_test_channel_minutes': 10,
|
||||
'monitor_setting.channel_test_concurrency': 1,
|
||||
'monitor_setting.channel_test_mode': 'scheduled_all',
|
||||
'channel_affinity_setting.enabled': false,
|
||||
'channel_affinity_setting.switch_on_success': true,
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import i18next from 'i18next'
|
||||
import { beforeAll, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import type { Message } from '../../../types'
|
||||
import { PlaygroundMessageEditor } from '../playground-message-editor'
|
||||
|
||||
const leavePrompt = 'You have unsaved changes. Are you sure you want to leave?'
|
||||
|
||||
const userMessage: Message = {
|
||||
key: 'msg-1',
|
||||
from: 'user',
|
||||
versions: [{ id: 'v1', content: 'original' }],
|
||||
}
|
||||
|
||||
function renderEditor(options: {
|
||||
editText: string
|
||||
onCancelEdit?: (open: boolean) => void
|
||||
}) {
|
||||
return render(
|
||||
<PlaygroundMessageEditor
|
||||
editText={options.editText}
|
||||
message={userMessage}
|
||||
onCancelEdit={options.onCancelEdit}
|
||||
onEditTextChange={() => undefined}
|
||||
originalText='original'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('PlaygroundMessageEditor leave warning', () => {
|
||||
beforeAll(() => {
|
||||
i18next.addResourceBundle('en', 'translation', {
|
||||
Cancel: 'Cancel',
|
||||
Leave: 'Leave',
|
||||
Stay: 'Stay',
|
||||
[leavePrompt]: leavePrompt,
|
||||
})
|
||||
})
|
||||
|
||||
test('cancels immediately when the edit has no unsaved changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancelEdit = vi.fn()
|
||||
|
||||
renderEditor({ editText: 'original', onCancelEdit })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(onCancelEdit).toHaveBeenCalledWith(false)
|
||||
expect(screen.queryByText(leavePrompt)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('leaves the editor after confirming unsaved changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancelEdit = vi.fn()
|
||||
|
||||
renderEditor({ editText: 'changed', onCancelEdit })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Leave' }))
|
||||
|
||||
expect(onCancelEdit).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
test('keeps the editor open after staying with unsaved changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancelEdit = vi.fn()
|
||||
|
||||
renderEditor({ editText: 'changed', onCancelEdit })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Stay' }))
|
||||
|
||||
expect(onCancelEdit).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(leavePrompt)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlaygroundMessageEditor beforeunload guard', () => {
|
||||
test('blocks page unload while the edit has unsaved changes', () => {
|
||||
renderEditor({ editText: 'changed' })
|
||||
|
||||
const event = new Event('beforeunload', { cancelable: true })
|
||||
window.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
test('stops blocking page unload after the edit reverts to the original text', () => {
|
||||
const { rerender } = renderEditor({ editText: 'changed' })
|
||||
|
||||
rerender(
|
||||
<PlaygroundMessageEditor
|
||||
editText='original'
|
||||
message={userMessage}
|
||||
onEditTextChange={() => undefined}
|
||||
originalText='original'
|
||||
/>
|
||||
)
|
||||
|
||||
const event = new Event('beforeunload', { cancelable: true })
|
||||
window.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -17,9 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Check, RotateCcw, Send, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CodeBlockEditor } from '@/components/ai-elements/code-block'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { getMessageEditorState } from '../../lib'
|
||||
@@ -45,28 +47,44 @@ export function PlaygroundMessageEditor({
|
||||
originalText,
|
||||
}: PlaygroundMessageEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [showLeaveDialog, setShowLeaveDialog] = useState(false)
|
||||
const { canSave, hasChanged, showSaveAndSubmit } = getMessageEditorState(
|
||||
message,
|
||||
editText,
|
||||
originalText
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasChanged) return
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
return ''
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [hasChanged])
|
||||
|
||||
const leaveEdit = () => {
|
||||
setShowLeaveDialog(false)
|
||||
onCancelEdit?.(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (
|
||||
hasChanged &&
|
||||
!window.confirm(
|
||||
t('You have unsaved changes. Are you sure you want to leave?')
|
||||
)
|
||||
) {
|
||||
if (hasChanged) {
|
||||
setShowLeaveDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
onCancelEdit?.(false)
|
||||
leaveEdit()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
if (showLeaveDialog) return
|
||||
handleCancel()
|
||||
return
|
||||
}
|
||||
@@ -133,6 +151,7 @@ export function PlaygroundMessageEditor({
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<CodeBlockEditor
|
||||
actions={editorActions}
|
||||
ariaLabel={t('Edit')}
|
||||
@@ -151,5 +170,18 @@ export function PlaygroundMessageEditor({
|
||||
}
|
||||
value={editText}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
cancelBtnText={t('Stay')}
|
||||
confirmText={t('Leave')}
|
||||
desc={t('You have unsaved changes. Are you sure you want to leave?')}
|
||||
destructive
|
||||
handleConfirm={leaveEdit}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setShowLeaveDialog(false)
|
||||
}}
|
||||
open={showLeaveDialog}
|
||||
title={t('Unsaved changes')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ const defaultModelSettings: ModelSettings = {
|
||||
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
|
||||
'monitor_setting.auto_test_channel_enabled': false,
|
||||
'monitor_setting.auto_test_channel_minutes': 10,
|
||||
'monitor_setting.channel_test_concurrency': 1,
|
||||
'monitor_setting.channel_test_mode': 'scheduled_all',
|
||||
'channel_affinity_setting.enabled': false,
|
||||
'channel_affinity_setting.switch_on_success': true,
|
||||
|
||||
@@ -69,8 +69,12 @@ const channelTestModes = [
|
||||
'passive_recovery',
|
||||
] as const
|
||||
type ChannelTestMode = (typeof channelTestModes)[number]
|
||||
const MAX_CHANNEL_TEST_CONCURRENCY = 32
|
||||
|
||||
const routingReliabilitySchema = z
|
||||
const createRoutingReliabilitySchema = (
|
||||
t: (key: string, options?: Record<string, unknown>) => string
|
||||
) =>
|
||||
z
|
||||
.object({
|
||||
RetryTimes: z.coerce.number().min(0).max(10),
|
||||
ChannelDisableThreshold: numericString,
|
||||
@@ -84,7 +88,15 @@ const routingReliabilitySchema = z
|
||||
auto_test_channel_minutes: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1, 'Interval must be at least 1 minute'),
|
||||
.min(1, t('Interval must be at least 1 minute')),
|
||||
channel_test_concurrency: z.coerce
|
||||
.number()
|
||||
.int(t('Enter a positive integer'))
|
||||
.min(1, t('Channel test concurrency must be between 1 and 32'))
|
||||
.max(
|
||||
MAX_CHANNEL_TEST_CONCURRENCY,
|
||||
t('Channel test concurrency must be between 1 and 32')
|
||||
),
|
||||
channel_test_mode: z.enum(channelTestModes),
|
||||
}),
|
||||
})
|
||||
@@ -96,9 +108,9 @@ const routingReliabilitySchema = z
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['AutomaticDisableStatusCodes'],
|
||||
message: `Invalid status code rules: ${disableParsed.invalidTokens.join(
|
||||
', '
|
||||
)}`,
|
||||
message: t('Invalid status code rules: {{tokens}}', {
|
||||
tokens: disableParsed.invalidTokens.join(', '),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -109,15 +121,18 @@ const routingReliabilitySchema = z
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['AutomaticRetryStatusCodes'],
|
||||
message: `Invalid status code rules: ${retryParsed.invalidTokens.join(
|
||||
', '
|
||||
)}`,
|
||||
message: t('Invalid status code rules: {{tokens}}', {
|
||||
tokens: retryParsed.invalidTokens.join(', '),
|
||||
}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type RoutingReliabilityFormValues = z.output<typeof routingReliabilitySchema>
|
||||
type RoutingReliabilityFormInput = z.input<typeof routingReliabilitySchema>
|
||||
type RoutingReliabilitySchema = ReturnType<
|
||||
typeof createRoutingReliabilitySchema
|
||||
>
|
||||
type RoutingReliabilityFormValues = z.output<RoutingReliabilitySchema>
|
||||
type RoutingReliabilityFormInput = z.input<RoutingReliabilitySchema>
|
||||
|
||||
type RoutingReliabilitySectionProps = {
|
||||
defaultValues: {
|
||||
@@ -130,6 +145,7 @@ type RoutingReliabilitySectionProps = {
|
||||
AutomaticRetryStatusCodes: string
|
||||
'monitor_setting.auto_test_channel_enabled': boolean
|
||||
'monitor_setting.auto_test_channel_minutes': number
|
||||
'monitor_setting.channel_test_concurrency': number
|
||||
'monitor_setting.channel_test_mode': ChannelTestMode
|
||||
}
|
||||
}
|
||||
@@ -148,6 +164,7 @@ type NormalizedRoutingReliabilityValues = {
|
||||
AutomaticRetryStatusCodes: string
|
||||
'monitor_setting.auto_test_channel_enabled': boolean
|
||||
'monitor_setting.auto_test_channel_minutes': number
|
||||
'monitor_setting.channel_test_concurrency': number
|
||||
'monitor_setting.channel_test_mode': ChannelTestMode
|
||||
}
|
||||
|
||||
@@ -175,6 +192,8 @@ const buildFormDefaults = (
|
||||
defaults['monitor_setting.auto_test_channel_enabled'],
|
||||
auto_test_channel_minutes:
|
||||
defaults['monitor_setting.auto_test_channel_minutes'],
|
||||
channel_test_concurrency:
|
||||
defaults['monitor_setting.channel_test_concurrency'],
|
||||
channel_test_mode: normalizeChannelTestMode(
|
||||
defaults['monitor_setting.channel_test_mode']
|
||||
),
|
||||
@@ -201,6 +220,8 @@ const normalizeDefaults = (
|
||||
defaults['monitor_setting.auto_test_channel_enabled'],
|
||||
'monitor_setting.auto_test_channel_minutes':
|
||||
defaults['monitor_setting.auto_test_channel_minutes'],
|
||||
'monitor_setting.channel_test_concurrency':
|
||||
defaults['monitor_setting.channel_test_concurrency'],
|
||||
'monitor_setting.channel_test_mode': normalizeChannelTestMode(
|
||||
defaults['monitor_setting.channel_test_mode']
|
||||
),
|
||||
@@ -226,6 +247,8 @@ const normalizeFormValues = (
|
||||
values.monitor_setting.auto_test_channel_enabled,
|
||||
'monitor_setting.auto_test_channel_minutes':
|
||||
values.monitor_setting.auto_test_channel_minutes,
|
||||
'monitor_setting.channel_test_concurrency':
|
||||
values.monitor_setting.channel_test_concurrency,
|
||||
'monitor_setting.channel_test_mode': values.monitor_setting.channel_test_mode,
|
||||
})
|
||||
|
||||
@@ -234,6 +257,7 @@ export function RoutingReliabilitySection({
|
||||
}: RoutingReliabilitySectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
const routingReliabilitySchema = createRoutingReliabilitySchema(t)
|
||||
const baselineRef = useRef<NormalizedRoutingReliabilityValues>(
|
||||
normalizeDefaults(defaultValues)
|
||||
)
|
||||
@@ -484,6 +508,31 @@ export function RoutingReliabilitySection({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='monitor_setting.channel_test_concurrency'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Channel test concurrency')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
max={MAX_CHANNEL_TEST_CONCURRENCY}
|
||||
step={1}
|
||||
{...safeNumberFieldProps(field)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Maximum number of channels tested at the same time (1-32)'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='AutomaticEnableChannelEnabled'
|
||||
|
||||
@@ -83,6 +83,8 @@ const MODELS_SECTIONS = [
|
||||
settings['monitor_setting.auto_test_channel_enabled'],
|
||||
'monitor_setting.auto_test_channel_minutes':
|
||||
settings['monitor_setting.auto_test_channel_minutes'],
|
||||
'monitor_setting.channel_test_concurrency':
|
||||
settings['monitor_setting.channel_test_concurrency'],
|
||||
'monitor_setting.channel_test_mode':
|
||||
settings['monitor_setting.channel_test_mode'],
|
||||
}}
|
||||
|
||||
@@ -235,6 +235,7 @@ export type ModelSettings = {
|
||||
AutomaticRetryStatusCodes: string
|
||||
'monitor_setting.auto_test_channel_enabled': boolean
|
||||
'monitor_setting.auto_test_channel_minutes': number
|
||||
'monitor_setting.channel_test_concurrency': number
|
||||
'monitor_setting.channel_test_mode':
|
||||
| 'scheduled_all'
|
||||
| 'auto_ban_only'
|
||||
|
||||
@@ -251,7 +251,9 @@ export function CommonLogsFilterBar<TData>(
|
||||
filters.requestId,
|
||||
filters.upstreamRequestId,
|
||||
].filter(Boolean).length
|
||||
const sensitiveType = sensitiveVisible ? 'text' : 'password'
|
||||
const sensitiveInputClass = sensitiveVisible
|
||||
? undefined
|
||||
: '[-webkit-text-security:disc]'
|
||||
const logTypeItems = useMemo(
|
||||
() =>
|
||||
LOG_TYPE_FILTERS.map((type) => ({
|
||||
@@ -315,7 +317,7 @@ export function CommonLogsFilterBar<TData>(
|
||||
<LogsFilterField>
|
||||
<LogsFilterInput
|
||||
placeholder={t('Group')}
|
||||
type={sensitiveType}
|
||||
className={sensitiveInputClass}
|
||||
value={filters.group || ''}
|
||||
onChange={(e) => handleChange('group', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -363,7 +365,7 @@ export function CommonLogsFilterBar<TData>(
|
||||
<LogsFilterField>
|
||||
<LogsFilterInput
|
||||
placeholder={t('Token Name')}
|
||||
type={sensitiveType}
|
||||
className={sensitiveInputClass}
|
||||
value={filters.token || ''}
|
||||
onChange={(e) => handleChange('token', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -373,7 +375,7 @@ export function CommonLogsFilterBar<TData>(
|
||||
<LogsFilterField>
|
||||
<LogsFilterInput
|
||||
placeholder={t('Username')}
|
||||
type={sensitiveType}
|
||||
className={sensitiveInputClass}
|
||||
value={filters.username || ''}
|
||||
onChange={(e) => handleChange('username', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -79,6 +79,7 @@ export function LogsFilterInput(props: ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
autoComplete='off'
|
||||
className={cn('h-8 min-w-0 text-sm leading-5', props.className)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -134,7 +134,7 @@ export interface LogOtherData {
|
||||
admin_role?: number
|
||||
auth_method?: 'session' | 'access_token' | string
|
||||
// Quota saturation marker: set when a quota conversion clamped at the
|
||||
// int32 bound (overflow/underflow) or hit a NaN fallback while computing
|
||||
// supported single-request bound (overflow/underflow) or hit a NaN fallback while computing
|
||||
// this request's charge. Admin-only (nested under admin_info).
|
||||
quota_saturation?: {
|
||||
op: string
|
||||
|
||||
@@ -776,6 +776,8 @@
|
||||
"Channel models": "Channel models",
|
||||
"Channel name is required": "Channel name is required",
|
||||
"Channel test completed": "Channel test completed",
|
||||
"Channel test concurrency": "Channel test concurrency",
|
||||
"Channel test concurrency must be between 1 and 32": "Channel test concurrency must be between 1 and 32",
|
||||
"Channel test mode": "Channel test mode",
|
||||
"Channel type is required": "Channel type is required",
|
||||
"Channel updated successfully": "Channel updated successfully",
|
||||
@@ -2377,6 +2379,7 @@
|
||||
"Internal Notes": "Internal Notes",
|
||||
"Internal notes (not shown to users)": "Internal notes (not shown to users)",
|
||||
"Internal Server Error!": "Internal Server Error!",
|
||||
"Interval must be at least 1 minute": "Interval must be at least 1 minute",
|
||||
"Invalid (NaN)": "Invalid (NaN)",
|
||||
"Invalid chat link. Please contact the administrator.": "Invalid chat link. Please contact the administrator.",
|
||||
"Invalid chat link. Please contact your administrator.": "Invalid chat link. Please contact your administrator.",
|
||||
@@ -2394,6 +2397,7 @@
|
||||
"Invalid reset link, please request a new password reset.": "Invalid reset link, please request a new password reset.",
|
||||
"Invalid rules JSON format": "Invalid rules JSON format",
|
||||
"Invalid status code mapping entries: {{entries}}": "Invalid status code mapping entries: {{entries}}",
|
||||
"Invalid status code rules: {{tokens}}": "Invalid status code rules: {{tokens}}",
|
||||
"Invalidate": "Invalidate",
|
||||
"Invalidated": "Invalidated",
|
||||
"Invert match": "Invert match",
|
||||
@@ -2650,6 +2654,7 @@
|
||||
"Maximum check-in quota": "Maximum check-in quota",
|
||||
"Maximum custom groups per token": "Maximum custom groups per token",
|
||||
"Maximum input window": "Maximum input window",
|
||||
"Maximum number of channels tested at the same time (1-32)": "Maximum number of channels tested at the same time (1-32)",
|
||||
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.",
|
||||
"Maximum number of tokens in the response": "Maximum number of tokens in the response",
|
||||
"Maximum quota amount awarded for check-in": "Maximum quota amount awarded for check-in",
|
||||
|
||||
@@ -776,6 +776,8 @@
|
||||
"Channel models": "Modèles de canaux",
|
||||
"Channel name is required": "Le nom du canal est requis",
|
||||
"Channel test completed": "Test du canal terminé",
|
||||
"Channel test concurrency": "Parallélisme des tests de canaux",
|
||||
"Channel test concurrency must be between 1 and 32": "Le parallélisme des tests de canaux doit être compris entre 1 et 32",
|
||||
"Channel test mode": "Mode de test des canaux",
|
||||
"Channel type is required": "Le type de canal est requis",
|
||||
"Channel updated successfully": "Canal mis à jour avec succès",
|
||||
@@ -2377,6 +2379,7 @@
|
||||
"Internal Notes": "Notes internes",
|
||||
"Internal notes (not shown to users)": "Notes internes (non visibles par les utilisateurs)",
|
||||
"Internal Server Error!": "Erreur interne du serveur !",
|
||||
"Interval must be at least 1 minute": "L'intervalle doit être d'au moins 1 minute",
|
||||
"Invalid (NaN)": "Invalide (NaN)",
|
||||
"Invalid chat link. Please contact the administrator.": "Lien de chat invalide. Veuillez contacter l'administrateur.",
|
||||
"Invalid chat link. Please contact your administrator.": "Lien de chat invalide. Veuillez contacter votre administrateur.",
|
||||
@@ -2394,6 +2397,7 @@
|
||||
"Invalid reset link, please request a new password reset.": "Lien de réinitialisation invalide, veuillez demander une nouvelle réinitialisation du mot de passe.",
|
||||
"Invalid rules JSON format": "Format JSON des règles invalide",
|
||||
"Invalid status code mapping entries: {{entries}}": "Entrées de mappage de code d'état invalides : {{entries}}",
|
||||
"Invalid status code rules: {{tokens}}": "Règles de code de statut invalides : {{tokens}}",
|
||||
"Invalidate": "Invalider",
|
||||
"Invalidated": "Invalidé",
|
||||
"Invert match": "Inverser la correspondance",
|
||||
@@ -2650,6 +2654,7 @@
|
||||
"Maximum check-in quota": "Quota maximum de connexion",
|
||||
"Maximum custom groups per token": "Nombre maximal de groupes personnalisés par jeton",
|
||||
"Maximum input window": "Fenêtre d'entrée maximale",
|
||||
"Maximum number of channels tested at the same time (1-32)": "Nombre maximal de canaux testés simultanément (1 à 32)",
|
||||
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Nombre maximum de jetons que chaque utilisateur peut créer. Par défaut 1000. Une valeur trop élevée peut affecter les performances.",
|
||||
"Maximum number of tokens in the response": "Nombre maximum de jetons dans la réponse",
|
||||
"Maximum quota amount awarded for check-in": "Montant maximum de quota attribué pour la connexion",
|
||||
|
||||
@@ -776,6 +776,8 @@
|
||||
"Channel models": "チャネルモデル",
|
||||
"Channel name is required": "チャネル名が必要です",
|
||||
"Channel test completed": "チャネルテストが完了しました",
|
||||
"Channel test concurrency": "チャンネルテストの同時実行数",
|
||||
"Channel test concurrency must be between 1 and 32": "チャンネルテストの同時実行数は1~32にしてください",
|
||||
"Channel test mode": "チャネルテストモード",
|
||||
"Channel type is required": "チャネルタイプが必要です",
|
||||
"Channel updated successfully": "チャネルが正常に更新されました",
|
||||
@@ -2377,6 +2379,7 @@
|
||||
"Internal Notes": "内部メモ",
|
||||
"Internal notes (not shown to users)": ":内部メモ(ユーザーには表示されません)",
|
||||
"Internal Server Error!": "内部サーバーエラー!",
|
||||
"Interval must be at least 1 minute": "間隔は1分以上にしてください",
|
||||
"Invalid (NaN)": "無効 (NaN)",
|
||||
"Invalid chat link. Please contact the administrator.": "無効なチャットリンクです。管理者に連絡してください。",
|
||||
"Invalid chat link. Please contact your administrator.": "無効なチャットリンクです。管理者に連絡してください。",
|
||||
@@ -2394,6 +2397,7 @@
|
||||
"Invalid reset link, please request a new password reset.": "無効なリセットリンクです。新しいパスワードリセットをリクエストしてください。",
|
||||
"Invalid rules JSON format": "ルール JSON の形式が不正です",
|
||||
"Invalid status code mapping entries: {{entries}}": "無効なステータスコードマッピング:{{entries}}",
|
||||
"Invalid status code rules: {{tokens}}": "無効なステータスコードルール:{{tokens}}",
|
||||
"Invalidate": "無効化",
|
||||
"Invalidated": "無効化済み",
|
||||
"Invert match": "一致を反転",
|
||||
@@ -2650,6 +2654,7 @@
|
||||
"Maximum check-in quota": "最大チェックインクォータ",
|
||||
"Maximum custom groups per token": "トークンごとのカスタムグループ上限",
|
||||
"Maximum input window": "最大入力ウィンドウ",
|
||||
"Maximum number of channels tested at the same time (1-32)": "同時にテストするチャンネルの最大数(1~32)",
|
||||
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "各ユーザーが作成できる最大トークン数。デフォルトは 1000。大きすぎる値はパフォーマンスに影響を与える可能性があります。",
|
||||
"Maximum number of tokens in the response": "レスポンスの最大トークン数",
|
||||
"Maximum quota amount awarded for check-in": "チェックインで付与される最大クォータ量",
|
||||
|
||||
@@ -776,6 +776,8 @@
|
||||
"Channel models": "Модели каналов",
|
||||
"Channel name is required": "Имя канала обязательно",
|
||||
"Channel test completed": "Тест канала завершён",
|
||||
"Channel test concurrency": "Параллельность проверки каналов",
|
||||
"Channel test concurrency must be between 1 and 32": "Параллельность проверки каналов должна быть от 1 до 32",
|
||||
"Channel test mode": "Режим проверки каналов",
|
||||
"Channel type is required": "Тип канала обязателен",
|
||||
"Channel updated successfully": "Канал успешно обновлён",
|
||||
@@ -2377,6 +2379,7 @@
|
||||
"Internal Notes": "Внутренние заметки",
|
||||
"Internal notes (not shown to users)": "Внутренние заметки (не показываются пользователям)",
|
||||
"Internal Server Error!": "Внутренняя ошибка сервера!",
|
||||
"Interval must be at least 1 minute": "Интервал должен быть не менее 1 минуты",
|
||||
"Invalid (NaN)": "Недопустимо (NaN)",
|
||||
"Invalid chat link. Please contact the administrator.": "Неверная ссылка на чат. Пожалуйста, обратитесь к администратору.",
|
||||
"Invalid chat link. Please contact your administrator.": "Недействительная ссылка чата. Обратитесь к администратору.",
|
||||
@@ -2394,6 +2397,7 @@
|
||||
"Invalid reset link, please request a new password reset.": "Недействительная ссылка для сброса, пожалуйста, запросите новый сброс пароля.",
|
||||
"Invalid rules JSON format": "Неверный формат JSON правил",
|
||||
"Invalid status code mapping entries: {{entries}}": "Недопустимые записи маппинга кодов состояния: {{entries}}",
|
||||
"Invalid status code rules: {{tokens}}": "Недопустимые правила кодов состояния: {{tokens}}",
|
||||
"Invalidate": "Аннулировать",
|
||||
"Invalidated": "Аннулирована",
|
||||
"Invert match": "Инвертировать совпадение",
|
||||
@@ -2650,6 +2654,7 @@
|
||||
"Maximum check-in quota": "Максимальная квота регистрации",
|
||||
"Maximum custom groups per token": "Максимум пользовательских групп на токен",
|
||||
"Maximum input window": "Максимальное окно ввода",
|
||||
"Maximum number of channels tested at the same time (1-32)": "Максимальное число одновременно проверяемых каналов (1–32)",
|
||||
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Максимальное количество токенов, которое может создать каждый пользователь. По умолчанию 1000. Слишком большое значение может повлиять на производительность.",
|
||||
"Maximum number of tokens in the response": "Максимальное число токенов в ответе",
|
||||
"Maximum quota amount awarded for check-in": "Максимальная сумма квоты, присуждаемая за регистрацию",
|
||||
|
||||
@@ -776,6 +776,8 @@
|
||||
"Channel models": "Mô hình kênh",
|
||||
"Channel name is required": "Tên kênh là bắt buộc",
|
||||
"Channel test completed": "Kiểm tra kênh hoàn tất",
|
||||
"Channel test concurrency": "Mức đồng thời khi kiểm tra kênh",
|
||||
"Channel test concurrency must be between 1 and 32": "Mức đồng thời khi kiểm tra kênh phải từ 1 đến 32",
|
||||
"Channel test mode": "Chế độ kiểm tra kênh",
|
||||
"Channel type is required": "Loại kênh là bắt buộc",
|
||||
"Channel updated successfully": "Kênh đã được cập nhật thành công",
|
||||
@@ -2377,6 +2379,7 @@
|
||||
"Internal Notes": "Ghi chú nội bộ",
|
||||
"Internal notes (not shown to users)": "Ghi chú nội bộ (không hiển thị cho người dùng)",
|
||||
"Internal Server Error!": "Lỗi máy chủ nội bộ!",
|
||||
"Interval must be at least 1 minute": "Khoảng thời gian phải ít nhất 1 phút",
|
||||
"Invalid (NaN)": "Không hợp lệ (NaN)",
|
||||
"Invalid chat link. Please contact the administrator.": "Liên kết trò chuyện không hợp lệ. Vui lòng liên hệ quản trị viên.",
|
||||
"Invalid chat link. Please contact your administrator.": "Liên kết trò chuyện không hợp lệ. Vui lòng liên hệ với quản trị viên của bạn.",
|
||||
@@ -2394,6 +2397,7 @@
|
||||
"Invalid reset link, please request a new password reset.": "Liên kết đặt lại không hợp lệ, vui lòng yêu cầu đặt lại mật khẩu mới.",
|
||||
"Invalid rules JSON format": "Định dạng JSON quy tắc không hợp lệ",
|
||||
"Invalid status code mapping entries: {{entries}}": "Mục ánh xạ mã trạng thái không hợp lệ: {{entries}}",
|
||||
"Invalid status code rules: {{tokens}}": "Quy tắc mã trạng thái không hợp lệ: {{tokens}}",
|
||||
"Invalidate": "Vô hiệu hóa",
|
||||
"Invalidated": "Đã vô hiệu",
|
||||
"Invert match": "Đảo điều kiện khớp",
|
||||
@@ -2650,6 +2654,7 @@
|
||||
"Maximum check-in quota": "Hạn ngạch điểm danh tối đa",
|
||||
"Maximum custom groups per token": "Số nhóm tùy chỉnh tối đa cho mỗi token",
|
||||
"Maximum input window": "Cửa sổ nhập tối đa",
|
||||
"Maximum number of channels tested at the same time (1-32)": "Số kênh tối đa được kiểm tra cùng lúc (1–32)",
|
||||
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Số lượng token tối đa mỗi người dùng có thể tạo. Mặc định là 1000. Đặt quá lớn có thể ảnh hưởng đến hiệu suất.",
|
||||
"Maximum number of tokens in the response": "Số token tối đa trong phản hồi",
|
||||
"Maximum quota amount awarded for check-in": "Số lượng hạn ngạch tối đa được trao cho điểm danh",
|
||||
|
||||
@@ -776,6 +776,8 @@
|
||||
"Channel models": "渠道模型",
|
||||
"Channel name is required": "渠道名稱是必填的",
|
||||
"Channel test completed": "渠道測試完成",
|
||||
"Channel test concurrency": "渠道測試並行數",
|
||||
"Channel test concurrency must be between 1 and 32": "渠道測試並行數必須介於 1 到 32 之間",
|
||||
"Channel test mode": "渠道測試模式",
|
||||
"Channel type is required": "渠道類型是必填的",
|
||||
"Channel updated successfully": "渠道更新成功",
|
||||
@@ -2377,6 +2379,7 @@
|
||||
"Internal Notes": "內部備註",
|
||||
"Internal notes (not shown to users)": "內部備註(不顯示給用戶)",
|
||||
"Internal Server Error!": "內部伺服器錯誤!",
|
||||
"Interval must be at least 1 minute": "間隔必須至少為 1 分鐘",
|
||||
"Invalid (NaN)": "無效 (NaN)",
|
||||
"Invalid chat link. Please contact the administrator.": "無效的聊天連結。請聯絡管理員。",
|
||||
"Invalid chat link. Please contact your administrator.": "無效的聊天連結。請聯絡您的管理員。",
|
||||
@@ -2394,6 +2397,7 @@
|
||||
"Invalid reset link, please request a new password reset.": "無效的重置連結,請請求新的密碼重置。",
|
||||
"Invalid rules JSON format": "規則 JSON 格式不正確",
|
||||
"Invalid status code mapping entries: {{entries}}": "無效的狀態碼映射條目:{{entries}}",
|
||||
"Invalid status code rules: {{tokens}}": "無效的狀態碼規則:{{tokens}}",
|
||||
"Invalidate": "作廢",
|
||||
"Invalidated": "已作廢",
|
||||
"Invert match": "反向匹配",
|
||||
@@ -2650,6 +2654,7 @@
|
||||
"Maximum check-in quota": "簽到最大額度",
|
||||
"Maximum custom groups per token": "每個令牌的最大自訂分組數",
|
||||
"Maximum input window": "最大輸入窗口",
|
||||
"Maximum number of channels tested at the same time (1-32)": "同時測試的最大渠道數(1-32)",
|
||||
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "每個用戶可建立的最大令牌數量。預設 1000。設定過大可能會影響效能。",
|
||||
"Maximum number of tokens in the response": "回應中最大 token 數",
|
||||
"Maximum quota amount awarded for check-in": "簽到獎勵的最大額度",
|
||||
|
||||
@@ -776,6 +776,8 @@
|
||||
"Channel models": "渠道模型",
|
||||
"Channel name is required": "渠道名称是必填的",
|
||||
"Channel test completed": "渠道测试完成",
|
||||
"Channel test concurrency": "渠道测试并发数",
|
||||
"Channel test concurrency must be between 1 and 32": "渠道测试并发数必须在 1 到 32 之间",
|
||||
"Channel test mode": "渠道测试模式",
|
||||
"Channel type is required": "渠道类型是必填的",
|
||||
"Channel updated successfully": "渠道更新成功",
|
||||
@@ -2377,6 +2379,7 @@
|
||||
"Internal Notes": "内部备注",
|
||||
"Internal notes (not shown to users)": "内部备注(不显示给用户)",
|
||||
"Internal Server Error!": "内部服务器错误!",
|
||||
"Interval must be at least 1 minute": "间隔必须至少为 1 分钟",
|
||||
"Invalid (NaN)": "无效 (NaN)",
|
||||
"Invalid chat link. Please contact the administrator.": "无效的聊天链接。请联系管理员。",
|
||||
"Invalid chat link. Please contact your administrator.": "无效的聊天链接。请联系您的管理员。",
|
||||
@@ -2394,6 +2397,7 @@
|
||||
"Invalid reset link, please request a new password reset.": "无效的重置链接,请请求新的密码重置。",
|
||||
"Invalid rules JSON format": "规则 JSON 格式不正确",
|
||||
"Invalid status code mapping entries: {{entries}}": "无效的状态码映射条目:{{entries}}",
|
||||
"Invalid status code rules: {{tokens}}": "无效的状态码规则:{{tokens}}",
|
||||
"Invalidate": "作废",
|
||||
"Invalidated": "已作废",
|
||||
"Invert match": "反向匹配",
|
||||
@@ -2650,6 +2654,7 @@
|
||||
"Maximum check-in quota": "签到最大额度",
|
||||
"Maximum custom groups per token": "每个令牌的最大自定义分组数",
|
||||
"Maximum input window": "最大输入窗口",
|
||||
"Maximum number of channels tested at the same time (1-32)": "同时测试的最大渠道数(1-32)",
|
||||
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "每个用户可创建的最大令牌数量。默认 1000。设置过大可能会影响性能。",
|
||||
"Maximum number of tokens in the response": "响应中最大 token 数",
|
||||
"Maximum quota amount awarded for check-in": "签到奖励的最大额度",
|
||||
|
||||
@@ -442,6 +442,28 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth streaming: one-shot fade-in on newly streamed words.
|
||||
* Duration must match FADE_DURATION_MS in response-fade.ts */
|
||||
@keyframes stream-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-stream-fade] {
|
||||
animation: stream-fade-in 250ms ease-out both;
|
||||
animation-delay: var(--stream-fade-delay, 0ms);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-stream-fade] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Landing page scroll-triggered animations ── */
|
||||
@keyframes landing-fade-up {
|
||||
from {
|
||||
|
||||
Reference in New Issue
Block a user