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 +}