diff --git a/MY_CHANGES_BACKUP.md b/MY_CHANGES_BACKUP.md
index a89cf6b6b805..e9ea14e43e17 100644
--- a/MY_CHANGES_BACKUP.md
+++ b/MY_CHANGES_BACKUP.md
@@ -8,7 +8,7 @@
172114422 fix(auth): keep login state on rate-limited or failing token refresh
```
-更新时间:2026-07-31。已逐个查看当前 fork 独有提交及当前工作区未提交改动,并按“当前工作区最终仍保留的差异”重新整理。本文档只记录本仓库内保留的源码差异,不记录外部部署过程或其他项目的构建流程。Redis Sentinel 支持和 Sentinel 故障转移重试逻辑已经从当前工作区移除,不再作为保留改动记录。
+更新时间:2026-08-02。已逐个查看当前 fork 独有提交及当前工作区未提交改动,并按“当前工作区最终仍保留的差异”重新整理。本文档只记录本仓库内保留的源码差异,不记录外部部署过程或其他项目的构建流程。Redis Sentinel 支持和 Sentinel 故障转移重试逻辑已经从当前工作区移除,不再作为保留改动记录。
## 当前保留的改动
@@ -212,8 +212,8 @@
- 统一按“语义输出”而不是响应体非空判断成功:文本、reasoning、refusal/content filter、音频/图片/代码结果和结构完整的工具调用均可构成有效输出;只有 role、usage、ping、start/stop、空 candidate/choice/content 等协议外壳不算输出。
- OpenAI Chat/Completions 同时校验非流式和流式响应;工具调用必须有函数名,聚合后的 arguments 必须是完整 JSON,并正确统计并行工具调用。
- OpenAI Responses API 校验 `completed`/`incomplete`/`failed` 状态、输出项及 function/custom tool call;流式工具调用按 item id 与 output index 关联,拒绝缺名或参数截断。
-- Claude 不再把任意合法 SSE 事件或 `stop_reason=tool_use` 的空 content 当作成功;工具流必须有 `tool_use` 块、完整 JSON 输入以及终止事件。
-- Gemini 拒绝空 candidate、空 parts、只有 usage 的流片段和无终止原因的截断流;安全过滤仍按明确拒绝处理,纯 function call 保持有效。
+- Claude 不再把任意合法 SSE 事件或 `stop_reason=tool_use` 的空 content 当作成功;工具流必须有 `tool_use` 块和完整 JSON 输入。缺少终止事件但已经产生语义输出的异常流按第 20 节处理。
+- Gemini 拒绝空 candidate、空 parts 和只有 usage 的流片段;安全过滤仍按明确拒绝处理,纯 function call 保持有效。缺少终止原因但已经产生语义输出的异常流按第 20 节处理。
- 在首个语义输出前缓存流式协议外壳,使真正的空回仍可在未写入客户端时触发渠道间重试;一旦响应已经写出则禁止切换渠道,避免把两个上游流拼接到同一客户端响应,并发送对应协议的终止错误事件。
- 每次渠道重试前重置 response count、stream status、首包时间、thinking/Claude 转换状态和 Responses 内置工具计数,防止上一渠道状态污染下一渠道。
- 删除不再使用的 40k stars light 海报 PNG/SVG。
@@ -343,7 +343,7 @@
- 内置版不提供服务器选择,激活、续期、激活码查询、令牌查询均使用当前域名下
的 `/api/activation/*`、`/v1/dashboard/billing/*` 和 `/api/log/token`。
- 令牌查询展示令牌名称、总额度、剩余额度、已用额度、过期时间和近期调用;
- 即使令牌尚无调用日志,也可从订阅接口读取令牌名称。
+ 即使令牌尚无调用日志,也可从订阅接口读取令牌名称。近期调用同时包含消费日志和错误日志,展示成功/错误状态与完整错误详情,便于用户直接定位失败请求。
- 页面文案覆盖 en、zh、zh-TW、fr、ja、ru、vi,并将内部 `zhCN` 语言代码映射为
合法的 Intl locale,避免日期格式化异常;密钥查询按钮在各非英语语言中使用本地化动作文案。
- 旧版 Xuancat 独立主页和关于页的 41 个无引用 i18n 键已从全部语言包移除。
@@ -446,3 +446,55 @@ Xuancat 前端“常规 / 激活码管理”中维护自己名下的激活码;
- `bunx oxlint src/features/key-batch-operations/index.tsx` 通过。
- Xuancat 与普通前端生产构建均通过。
- Xuancat Windows amd64、Xuancat Linux amd64、普通版 Linux amd64 二进制均编译成功。
+
+### 20. 非空异常流按完成请求计费
+
+调整 OpenAI Chat/Completions、OpenAI Responses、Claude 和 Gemini 的流式响应终止策略:终止事件完整性与语义输出有效性分开判断,避免上游已经返回有效内容、但连接在终止帧前断开时整单退款。
+
+保留行为:
+
+- “空回”继续沿用统一语义校验定义:只有 role、usage、ping、start/stop、空 choice/candidate/content 等协议外壳不算输出;文本、reasoning、refusal/过滤结果、音频/图片/代码结果和结构完整的工具调用才算有效输出。
+- 真正空回仍返回 `ErrorCodeEmptyResponse`,进入重试/错误处理并退还预扣额度。
+- 已经产生语义输出但缺少 `finish_reason`、`[DONE]`、`message_stop`、`response.completed`、`response.incomplete` 或 Gemini terminal finish reason 时,请求按完成处理并进入正常 usage 计算和计费结算。
+- 非空异常流不会生成普通请求错误日志;消费日志通过 `other.stream_status` 保留 `status=error`、`end_reason`、`end_error` 和软错误信息,因此前端仍会标记“流异常”。
+- `client_gone`、timeout、scanner error、EOF 等由现有 `StreamStatus` 记录;只要已经获得有效语义输出且响应解析本身没有硬错误,就不会因缺少终止事件退款。
+- 不完整 JSON 工具参数、缺失工具名、无法解析的 chunk 或明确的上游错误仍按硬错误处理,不会仅因为此前出现过文本就绕过协议校验。
+
+主要文件:
+
+| 文件 | 说明 |
+| ---- | ---- |
+| `relay/responsevalidator/validator.go` | 将语义输出校验拆分为 `ValidateOutput`,终止完整性继续由完整 `Validate` 检查 |
+| `relay/channel/openai/relay-openai.go` | Chat/Completions 非空截断流继续估算 usage、返回成功并记录流异常 |
+| `relay/channel/openai/relay_responses.go` | Responses 非空截断流继续结算并记录缺失 terminal event |
+| `relay/channel/claude/relay-claude.go` | Claude 非空截断流使用现有 fallback usage 结算并记录缺失 `message_stop` |
+| `relay/channel/gemini/relay-gemini.go` | Gemini 已发送有效响应但无终止原因时继续结算并记录流异常 |
+| `relay/channel/openai/chat_stream_test.go` | OpenAI 非空 EOF 正常返回 usage、空流仍拒绝的端到端回归测试 |
+| `relay/responsevalidator/validator_test.go` | 语义输出有效性与终止完整性分离测试 |
+
+验证:
+
+- `go test ./relay/...` 通过。
+- OpenAI、Responses、Claude、Gemini 和 billing service 定向测试通过。
+
+### 21. Claude 服务端工具流参数校验
+
+修复 Claude Web Search、Code Execution 等服务端工具调用在经过 relay 时被提前截断的问题。Anthropic 的 `server_tool_use` 与普通 `tool_use` 一样,会在 `content_block_start` 后通过 `input_json_delta` 分片发送参数;旧校验器只登记普通工具,因此会把服务端工具的第一个参数分片误判为“参数早于工具块”,随后终止 SSE。
+
+保留行为:
+
+- `server_tool_use` 与普通 `tool_use` 都按 content block index 登记名称和参数流。
+- Web Search 和 Code Execution 的空起始分片及后续 JSON 参数分片能够完整通过校验,不再丢失工具结果、最终文本和 `message_stop`。
+- 服务端工具缺少名称或累计参数不是完整 JSON 时仍然返回协议错误,不放宽原有响应完整性保护。
+
+主要文件:
+
+| 文件 | 说明 |
+| ---- | ---- |
+| `relay/responsevalidator/validator.go` | 将 `server_tool_use` 纳入流式工具参数关联和完整性校验 |
+| `relay/responsevalidator/validator_test.go` | Web Search、Code Execution 服务端工具参数流回归测试 |
+
+验证:
+
+- `go test ./relay/responsevalidator ./relay/channel/claude` 通过。
+- `go test ./relay/...` 通过。
diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go
index 6a1f78d3f163..d7ab99ed06d9 100644
--- a/relay/channel/claude/relay-claude.go
+++ b/relay/channel/claude/relay-claude.go
@@ -257,13 +257,16 @@ func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.
if streamErr != nil {
return nil, streamErr
}
- if err := streamState.Validate(); err != nil {
+ if err := streamState.ValidateOutput(); err != nil {
code := types.ErrorCodeBadResponse
if !streamState.Valid() {
code = types.ErrorCodeEmptyResponse
}
return nil, types.NewOpenAIError(err, code, http.StatusInternalServerError)
}
+ if !streamState.Terminal && info.StreamStatus != nil {
+ info.StreamStatus.RecordError("stream ended before message_stop")
+ }
if !streamCommitted {
for _, pending := range pendingData {
if apiErr := HandleStreamResponseData(c, info, claudeInfo, pending); apiErr != nil {
diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go
index c20a5886a5d4..6d362cc578d3 100644
--- a/relay/channel/gemini/relay-gemini.go
+++ b/relay/channel/gemini/relay-gemini.go
@@ -415,8 +415,8 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
if info.SendResponseCount == 0 {
return nil, types.NewOpenAIError(errors.New("empty response from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError)
}
- if !sawTerminal {
- return nil, types.NewOpenAIError(errors.New("Gemini stream ended before a terminal finish reason"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ if !sawTerminal && info.StreamStatus != nil {
+ info.StreamStatus.RecordError("Gemini stream ended before a terminal finish reason")
}
response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage)
diff --git a/relay/channel/openai/chat_stream_test.go b/relay/channel/openai/chat_stream_test.go
new file mode 100644
index 000000000000..5706e9ca85c9
--- /dev/null
+++ b/relay/channel/openai/chat_stream_test.go
@@ -0,0 +1,82 @@
+package openai
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/constant"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ relayconstant "github.com/QuantumNous/new-api/relay/constant"
+ "github.com/QuantumNous/new-api/types"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestOaiStreamHandlerBillsSemanticOutputWithoutTerminalEvent(t *testing.T) {
+ oldMode := gin.Mode()
+ gin.SetMode(gin.TestMode)
+ t.Cleanup(func() { gin.SetMode(oldMode) })
+
+ oldTimeout := constant.StreamingTimeout
+ constant.StreamingTimeout = 30
+ t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
+
+ body := "data: {\"id\":\"chatcmpl_partial\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-test\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"partial output\"}}]}\n\n"
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(body)),
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ }
+ info := &relaycommon.RelayInfo{
+ IsStream: true,
+ RelayMode: relayconstant.RelayModeChatCompletions,
+ RelayFormat: types.RelayFormatOpenAI,
+ ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-test"},
+ }
+ info.SetEstimatePromptTokens(3)
+
+ usage, apiErr := OaiStreamHandler(c, info, resp)
+
+ require.Nil(t, apiErr)
+ require.NotNil(t, usage)
+ assert.Equal(t, 3, usage.PromptTokens)
+ assert.Greater(t, usage.CompletionTokens, 0)
+ require.NotNil(t, info.StreamStatus)
+ assert.Equal(t, relaycommon.StreamEndReasonEOF, info.StreamStatus.EndReason)
+ assert.True(t, info.StreamStatus.HasErrors())
+ assert.Contains(t, info.StreamStatus.Errors[0].Message, "terminal event")
+}
+
+func TestOaiStreamHandlerStillRejectsEmptyStream(t *testing.T) {
+ oldTimeout := constant.StreamingTimeout
+ constant.StreamingTimeout = 30
+ t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader("")),
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ }
+ info := &relaycommon.RelayInfo{
+ IsStream: true,
+ RelayMode: relayconstant.RelayModeChatCompletions,
+ RelayFormat: types.RelayFormatOpenAI,
+ ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-test"},
+ }
+
+ usage, apiErr := OaiStreamHandler(c, info, resp)
+
+ assert.Nil(t, usage)
+ require.NotNil(t, apiErr)
+ assert.Equal(t, types.ErrorCodeEmptyResponse, apiErr.GetErrorCode())
+}
diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go
index c561b0f7cd35..21ab0cc344d6 100644
--- a/relay/channel/openai/relay-openai.go
+++ b/relay/channel/openai/relay-openai.go
@@ -191,13 +191,16 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
return nil, streamErr
}
done := info.StreamStatus != nil && info.StreamStatus.EndReason == relaycommon.StreamEndReasonDone
- if err := streamState.Validate(done); err != nil {
+ if err := streamState.ValidateOutput(); err != nil {
code := types.ErrorCodeBadResponse
if !streamState.Valid() {
code = types.ErrorCodeEmptyResponse
}
return nil, types.NewOpenAIError(err, code, http.StatusInternalServerError)
}
+ if !streamState.Terminal && !done && info.StreamStatus != nil {
+ info.StreamStatus.RecordError("stream ended before a terminal event")
+ }
if !streamCommitted {
for _, pending := range pendingData {
if err := HandleStreamFormat(c, info, pending, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent); err != nil {
diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go
index f9c4d4471c07..1ea2fbfdeeae 100644
--- a/relay/channel/openai/relay_responses.go
+++ b/relay/channel/openai/relay_responses.go
@@ -168,13 +168,16 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
if streamErr != nil {
return nil, streamErr
}
- if err := streamState.Validate(); err != nil {
+ if err := streamState.ValidateOutput(); err != nil {
code := types.ErrorCodeBadResponse
if !streamState.Valid() {
code = types.ErrorCodeEmptyResponse
}
return nil, types.NewOpenAIError(err, code, http.StatusInternalServerError)
}
+ if !streamState.Terminal && info.StreamStatus != nil {
+ info.StreamStatus.RecordError("stream ended before response.completed or response.incomplete")
+ }
if !streamCommitted {
for _, item := range pending {
sendResponsesStreamData(c, item.response, item.data)
diff --git a/relay/responsevalidator/validator.go b/relay/responsevalidator/validator.go
index b3024d52c11f..0930daaaacb9 100644
--- a/relay/responsevalidator/validator.go
+++ b/relay/responsevalidator/validator.go
@@ -184,6 +184,20 @@ func (s *OpenAIStreamState) ObserveCompletion(response *dto.CompletionsStreamRes
}
func (s *OpenAIStreamState) Validate(done bool) error {
+ if err := s.ValidateOutput(); err != nil {
+ return err
+ }
+ if !s.Terminal && !done {
+ return fmt.Errorf("stream ended before a terminal event")
+ }
+ return nil
+}
+
+// ValidateOutput verifies that a stream contains billable semantic output.
+// Terminal framing is intentionally checked by Validate so relay handlers can
+// accept and bill a non-empty stream that ended abnormally while still
+// recording the abnormal StreamStatus.
+func (s *OpenAIStreamState) ValidateOutput() error {
if s == nil {
return fmt.Errorf("stream state is nil")
}
@@ -199,9 +213,6 @@ func (s *OpenAIStreamState) Validate(done bool) error {
if !s.Valid() {
return fmt.Errorf("stream contained no semantic output")
}
- if !s.Terminal && !done {
- return fmt.Errorf("stream ended before a terminal event")
- }
return nil
}
@@ -268,15 +279,16 @@ func (s *ClaudeStreamState) Observe(response *dto.ClaudeResponse) error {
}
block := response.ContentBlock
switch block.Type {
- case "tool_use":
+ case "tool_use", "server_tool_use":
if strings.TrimSpace(block.Name) == "" {
- return fmt.Errorf("tool_use block has no name")
+ return fmt.Errorf("%s block has no name", block.Type)
}
index := response.GetIndex()
s.tools[index] = &streamedToolCall{name: block.Name}
+ s.Output = true
case "text":
s.Output = s.Output || block.GetText() != ""
- case "thinking", "redacted_thinking", "server_tool_use", "web_search_tool_result":
+ case "thinking", "redacted_thinking", "web_search_tool_result":
s.Output = true
}
case "content_block_delta":
@@ -313,6 +325,16 @@ func (s *ClaudeStreamState) Observe(response *dto.ClaudeResponse) error {
}
func (s *ClaudeStreamState) Validate() error {
+ if err := s.ValidateOutput(); err != nil {
+ return err
+ }
+ if !s.Terminal {
+ return fmt.Errorf("stream ended before message_stop")
+ }
+ return nil
+}
+
+func (s *ClaudeStreamState) ValidateOutput() error {
if s == nil {
return fmt.Errorf("stream state is nil")
}
@@ -328,9 +350,6 @@ func (s *ClaudeStreamState) Validate() error {
if !s.Valid() {
return fmt.Errorf("stream contained no semantic output")
}
- if !s.Terminal {
- return fmt.Errorf("stream ended before message_stop")
- }
return nil
}
@@ -532,6 +551,16 @@ func (s *ResponsesStreamState) Observe(event *dto.ResponsesStreamResponse) error
}
func (s *ResponsesStreamState) Validate() error {
+ if err := s.ValidateOutput(); err != nil {
+ return err
+ }
+ if !s.Terminal {
+ return fmt.Errorf("stream ended before response.completed or response.incomplete")
+ }
+ return nil
+}
+
+func (s *ResponsesStreamState) ValidateOutput() error {
if s == nil {
return fmt.Errorf("stream state is nil")
}
@@ -552,8 +581,5 @@ func (s *ResponsesStreamState) Validate() error {
if !s.Valid() {
return fmt.Errorf("stream contained no semantic output")
}
- if !s.Terminal {
- return fmt.Errorf("stream ended before response.completed or response.incomplete")
- }
return nil
}
diff --git a/relay/responsevalidator/validator_test.go b/relay/responsevalidator/validator_test.go
index e0c3d2888c71..45dca386c941 100644
--- a/relay/responsevalidator/validator_test.go
+++ b/relay/responsevalidator/validator_test.go
@@ -74,10 +74,27 @@ func TestOpenAIStreamStateToolOnlyAndTerminalRules(t *testing.T) {
unterminated := NewOpenAIStreamState()
require.NoError(t, unterminated.Observe(decode[dto.ChatCompletionsStreamResponse](t,
`{"choices":[{"index":0,"delta":{"content":"partial"}}]}`)))
+ require.NoError(t, unterminated.ValidateOutput())
require.Error(t, unterminated.Validate(false))
require.NoError(t, unterminated.Validate(true))
}
+func TestStreamValidateOutputSeparatesSemanticOutputFromTermination(t *testing.T) {
+ t.Parallel()
+
+ claude := NewClaudeStreamState()
+ require.NoError(t, claude.Observe(decode[dto.ClaudeResponse](t,
+ `{"type":"content_block_delta","delta":{"type":"text_delta","text":"partial"}}`)))
+ require.NoError(t, claude.ValidateOutput())
+ require.Error(t, claude.Validate())
+
+ responses := NewResponsesStreamState()
+ require.NoError(t, responses.Observe(decode[dto.ResponsesStreamResponse](t,
+ `{"type":"response.output_text.delta","delta":"partial"}`)))
+ require.NoError(t, responses.ValidateOutput())
+ require.Error(t, responses.Validate())
+}
+
func TestClaudeSemanticOutput(t *testing.T) {
t.Parallel()
@@ -105,6 +122,27 @@ func TestClaudeSemanticOutput(t *testing.T) {
require.NoError(t, state.Validate())
}
+func TestClaudeServerToolUseAcceptsStreamedArguments(t *testing.T) {
+ t.Parallel()
+
+ for _, toolName := range []string{"web_search", "code_execution"} {
+ toolName := toolName
+ t.Run(toolName, func(t *testing.T) {
+ t.Parallel()
+
+ state := NewClaudeStreamState()
+ require.NoError(t, state.Observe(decode[dto.ClaudeResponse](t,
+ `{"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","name":"`+toolName+`"}}`)))
+ require.NoError(t, state.Observe(decode[dto.ClaudeResponse](t,
+ `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)))
+ require.NoError(t, state.Observe(decode[dto.ClaudeResponse](t,
+ `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"check\"}"}}`)))
+ require.NoError(t, state.Observe(decode[dto.ClaudeResponse](t, `{"type":"message_stop"}`)))
+ require.NoError(t, state.Validate())
+ })
+ }
+}
+
func TestGeminiSemanticOutput(t *testing.T) {
t.Parallel()
diff --git a/web/src/features/system-settings/request-limits/token-daily-rate-limit-section.tsx b/web/src/features/system-settings/request-limits/token-daily-rate-limit-section.tsx
index 127a062c336f..84c7bb2b150b 100644
--- a/web/src/features/system-settings/request-limits/token-daily-rate-limit-section.tsx
+++ b/web/src/features/system-settings/request-limits/token-daily-rate-limit-section.tsx
@@ -16,12 +16,13 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import { useEffect, useState } from 'react'
-import * as z from 'zod'
-import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Code2, Palette } from 'lucide-react'
+import { useEffect, useState } from 'react'
+import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
+import * as z from 'zod'
+
import { Button } from '@/components/ui/button'
import {
Form,
@@ -35,6 +36,7 @@ import {
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
+
import {
SettingsForm,
SettingsSwitchContent,
diff --git a/web/src/features/system-settings/request-limits/token-rate-limit-section.tsx b/web/src/features/system-settings/request-limits/token-rate-limit-section.tsx
index 4690a03b2005..b4818f9eb77c 100644
--- a/web/src/features/system-settings/request-limits/token-rate-limit-section.tsx
+++ b/web/src/features/system-settings/request-limits/token-rate-limit-section.tsx
@@ -16,12 +16,13 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import { useEffect, useState } from 'react'
-import * as z from 'zod'
-import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Code2, Palette } from 'lucide-react'
+import { useEffect, useState } from 'react'
+import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
+import * as z from 'zod'
+
import { Button } from '@/components/ui/button'
import {
Form,
@@ -35,6 +36,7 @@ import {
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
+
import {
SettingsForm,
SettingsSwitchContent,
diff --git a/web/src/features/xuancat-pages/__tests__/key-query-logs.test.ts b/web/src/features/xuancat-pages/__tests__/key-query-logs.test.ts
new file mode 100644
index 000000000000..94b2e6cf8e4b
--- /dev/null
+++ b/web/src/features/xuancat-pages/__tests__/key-query-logs.test.ts
@@ -0,0 +1,32 @@
+/*
+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 .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import assert from 'node:assert/strict'
+import { describe, test } from 'node:test'
+
+import { isKeyQueryLog } from '../utils'
+
+describe('API key query logs', () => {
+ test('includes consumption and error calls while excluding unrelated account logs', () => {
+ assert.equal(isKeyQueryLog(0), true)
+ assert.equal(isKeyQueryLog(2), true)
+ assert.equal(isKeyQueryLog(5), true)
+ assert.equal(isKeyQueryLog(1), false)
+ assert.equal(isKeyQueryLog(6), false)
+ })
+})
diff --git a/web/src/features/xuancat-pages/components/key-query-dialog.tsx b/web/src/features/xuancat-pages/components/key-query-dialog.tsx
index dec77dabae3d..b7c31e7a4c2a 100644
--- a/web/src/features/xuancat-pages/components/key-query-dialog.tsx
+++ b/web/src/features/xuancat-pages/components/key-query-dialog.tsx
@@ -50,7 +50,12 @@ import {
import { getSubscription, getUsage, getUsageLogs } from '../api'
import type { TokenSummary, UsageLog } from '../types'
-import { formatQuota, formatTimestamp, getUsageDateRange } from '../utils'
+import {
+ formatQuota,
+ formatTimestamp,
+ getUsageDateRange,
+ isKeyQueryLog,
+} from '../utils'
type KeyQueryDialogProps = {
open: boolean
@@ -94,7 +99,7 @@ export function KeyQueryDialog(props: KeyQueryDialogProps) {
const usage = usageData.total_usage / 100
const unlimited = balance === 100_000_000
const usageLogs = (logData.success ? (logData.data ?? []) : []).filter(
- (log) => log.type === 0 || log.type === 2
+ (log) => isKeyQueryLog(log.type)
)
const tokenName =
subscription.token_name ||
@@ -213,15 +218,20 @@ export function KeyQueryDialog(props: KeyQueryDialogProps) {
{t('Time')}
{t('Model')}
{t('Group')}
+ {t('Status')}
{t('Duration')}
{t('Prompt')}
{t('Completion')}
{t('Cost')}
+ {t('Details')}
{visibleLogs.map((log, index) => (
{log.group || 'default'}
+
+
+ {log.type === 5 ? t('Error') : t('Success')}
+
+
{t('{{count}} seconds', {
count: Number(log.use_time),
@@ -251,6 +270,9 @@ export function KeyQueryDialog(props: KeyQueryDialogProps) {
{log.completion_tokens || '-'}
${formatQuota(log.quota)}
+
+ {log.content || '-'}
+
))}
diff --git a/web/src/features/xuancat-pages/types.ts b/web/src/features/xuancat-pages/types.ts
index 3b20b57a5d88..b9043c752ca4 100644
--- a/web/src/features/xuancat-pages/types.ts
+++ b/web/src/features/xuancat-pages/types.ts
@@ -60,6 +60,7 @@ export type UsageResponse = {
export type UsageLog = {
id?: number
type?: number
+ content?: string
token_name?: string
created_at: number
model_name: string
diff --git a/web/src/features/xuancat-pages/utils.ts b/web/src/features/xuancat-pages/utils.ts
index bce8f6bd9b85..c6f9aa12ca5e 100644
--- a/web/src/features/xuancat-pages/utils.ts
+++ b/web/src/features/xuancat-pages/utils.ts
@@ -59,3 +59,7 @@ export function getUsageDateRange(days = 100): {
export function formatQuota(quota: number): string {
return (quota / 500_000).toFixed(6)
}
+
+export function isKeyQueryLog(type: number | undefined): boolean {
+ return type === 0 || type === 2 || type === 5
+}