diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index b71ef953d561..18698c95c642 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -47,6 +47,7 @@ const ( // TestEvent represents a SSE event for account testing type TestEvent struct { OutputLimited bool `json:"output_limited,omitempty"` + ReasoningTokens *int `json:"reasoning_tokens,omitempty"` // OpenAI Responses test_complete; absent when unreported RequestedReasoningEffort string `json:"requested_reasoning_effort,omitempty"` EffectiveReasoningEffort string `json:"effective_reasoning_effort,omitempty"` Type string `json:"type"` diff --git a/backend/internal/service/account_test_stream.go b/backend/internal/service/account_test_stream.go index a7ddf5591986..c0dded7f19a7 100644 --- a/backend/internal/service/account_test_stream.go +++ b/backend/internal/service/account_test_stream.go @@ -369,12 +369,32 @@ func parseAccountConnectionStream(protocol string, body io.Reader, allowMedia bo return false, "", p.incomplete() } +// responsesReasoningTokens reads response.usage.output_tokens_details.reasoning_tokens +// from a Responses stream event. +func responsesReasoningTokens(data map[string]any) (int, bool) { + response, _ := data["response"].(map[string]any) + usage, _ := response["usage"].(map[string]any) + details, _ := usage["output_tokens_details"].(map[string]any) + tokens, ok := details["reasoning_tokens"].(float64) + if !ok || tokens < 0 { + return 0, false + } + return int(tokens), true +} + func (s *AccountTestService) processConnectionStream(c *gin.Context, body io.Reader, protocol string, account *Account) error { + var reasoningTokens *int limited, upstreamModel, err := parseAccountConnectionStream(protocol, body, c.GetBool("account_test_allow_media"), func(event TestEvent) { s.sendEvent(c, event) }, func(data map[string]any) { if account != nil && (data["error"] != nil || data["type"] == "response.failed" || data["type"] == "error") { raw, _ := json.Marshal(data) s.markOpenAIBudgetExceededFromTest(c.Request.Context(), account, http.StatusOK, raw) } + // OpenAI accounts only: the admin quality check reads this count. + if protocol == "responses" && account.IsOpenAI() { + if tokens, ok := responsesReasoningTokens(data); ok { + reasoningTokens = &tokens + } + } }) if err != nil { return s.sendTestFailure(c, err) @@ -385,6 +405,6 @@ func (s *AccountTestService) processConnectionStream(c *gin.Context, body io.Rea if upstreamModel != "" { s.sendEvent(c, TestEvent{Type: "upstream_model", UpstreamModel: upstreamModel}) } - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true, OutputLimited: limited}) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true, OutputLimited: limited, ReasoningTokens: reasoningTokens}) return nil } diff --git a/backend/internal/service/account_test_stream_contract_test.go b/backend/internal/service/account_test_stream_contract_test.go index 8bc88ae501fa..0e95c010f47e 100644 --- a/backend/internal/service/account_test_stream_contract_test.go +++ b/backend/internal/service/account_test_stream_contract_test.go @@ -97,6 +97,40 @@ func TestAccountConnectionStreamContract(t *testing.T) { } } +func TestAccountConnectionStreamReportsOpenAIReasoningTokens(t *testing.T) { + frame := func(data string) string { return "data: " + data + "\n\n" } + run := func(account *Account, wire string) (string, map[string]TestEvent) { + c, recorder := newTestContext() + require.NoError(t, (&AccountTestService{}).processConnectionStream(c, strings.NewReader(wire), "responses", account)) + events := map[string]TestEvent{} + for _, line := range strings.Split(recorder.Body.String(), "\n") { + if strings.HasPrefix(line, "data: ") { + var event TestEvent + require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event)) + events[event.Type] = event + } + } + return recorder.Body.String(), events + } + wire := frame(`{"type":"response.created","response":{"model":"gpt-6-astra","usage":null}}`) + + frame(`{"type":"response.output_text.delta","delta":"最少需要取出 21 颗。"}`) + + frame(`{"type":"response.completed","response":{"status":"completed","model":"gpt-6-astra","usage":{"input_tokens":40,"output_tokens":900,"output_tokens_details":{"reasoning_tokens":516}}}}`) + + _, events := run(&Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, wire) + require.Equal(t, "gpt-6-astra", events["upstream_model"].UpstreamModel) + require.True(t, events["test_complete"].Success) + require.NotNil(t, events["test_complete"].ReasoningTokens) + require.Equal(t, 516, *events["test_complete"].ReasoningTokens) + + // Other platforms and streams without usage keep the previous event shape. + body, _ := run(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth}, wire) + require.NotContains(t, body, "reasoning_tokens") + body, events = run(&Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + frame(`{"type":"response.output_text.delta","delta":"OK"}`)+frame(`{"type":"response.completed","response":{"status":"completed"}}`)) + require.True(t, events["test_complete"].Success) + require.NotContains(t, body, "reasoning_tokens") +} + func TestAccountJobTerminalAndRetryContract(t *testing.T) { require.Equal(t, AccountJobStatusFailed, AccountJobTerminalStatus(false, "cancel_check_failed", 1, 0, 0)) require.Equal(t, AccountJobStatusCanceled, AccountJobTerminalStatus(true, "", 1, 1, 2)) diff --git a/frontend/src/components/admin/account/AccountTestModal.vue b/frontend/src/components/admin/account/AccountTestModal.vue index 27e891de400f..44f73ab342a4 100644 --- a/frontend/src/components/admin/account/AccountTestModal.vue +++ b/frontend/src/components/admin/account/AccountTestModal.vue @@ -75,15 +75,27 @@ {{ t('admin.accounts.openai.testMode') }}