From b13200d7a18486797f77b7833f4b2e35c00be7ff Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sat, 19 Sep 2026 21:26:18 +0800 Subject: [PATCH 01/42] fix(gateway): forward received chat stream usage --- .../anthropic_chat_stream_usage_test.go | 111 ++++++++++++++++++ .../gateway_forward_as_chat_completions.go | 22 +++- ...ateway_forward_as_chat_completions_test.go | 4 +- ...enai_gateway_anthropic_native_pump_test.go | 4 +- ...teway_chat_completions_anthropic_native.go | 9 +- 5 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 backend/internal/service/anthropic_chat_stream_usage_test.go diff --git a/backend/internal/service/anthropic_chat_stream_usage_test.go b/backend/internal/service/anthropic_chat_stream_usage_test.go new file mode 100644 index 000000000000..99ce4d278954 --- /dev/null +++ b/backend/internal/service/anthropic_chat_stream_usage_test.go @@ -0,0 +1,111 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestAnthropicChatStreamAuthoritativeUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, adapter := range []string{"anthropic", "native"} { + for _, options := range []string{"", `,"stream_options":{"include_usage":true}`, `,"stream_options":{"include_usage":false}`} { + for _, tc := range []struct { + name, start, delta string + want bool + prompt, output, cached, created int + }{ + {"cache", `,"usage":{"input_tokens":308,"cache_read_input_tokens":241,"cache_creation_input_tokens":17}`, `,"usage":{"output_tokens":49}`, true, 566, 49, 241, 17}, + {"zero_start", `,"usage":{"input_tokens":0,"output_tokens":0}`, "", true, 0, 0, 0, 0}, + {"zero_delta", "", `,"usage":{"input_tokens":0,"output_tokens":0}`, true, 0, 0, 0, 0}, + {"absent", "", "", false, 0, 0, 0, 0}, + {"null", `,"usage":null`, `,"usage":null`, false, 0, 0, 0, 0}, + } { + for _, terminal := range []string{"stop", "eof"} { + t.Run(adapter+"/"+options+"/"+tc.name+"/"+terminal, func(t *testing.T) { + sse := fmt.Sprintf("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"k3\",\"content\":[]%s}}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}%s}\n\n", tc.start, tc.delta) + if terminal == "stop" { + // Repeated terminal events and EOF must not duplicate usage. + sse += "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + body := []byte(`{"model":"k3","messages":[{"role":"user","content":"hi"}],"stream":true` + options + "}") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body))) + if adapter == "native" { + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + result, err := svc.forwardChatCompletionsViaNativeAnthropic(context.Background(), c, nativeAnthropicTestAccount(), body, "") + require.NoError(t, err) + require.Equal(t, tc.output, result.Usage.OutputTokens) + require.Equal(t, tc.cached, result.Usage.CacheReadInputTokens) + require.Equal(t, tc.created, result.Usage.CacheCreationInputTokens) + } else { + svc := &GatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk-test"}} + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, nil) + require.NoError(t, err) + require.Equal(t, tc.output, result.Usage.OutputTokens) + require.Equal(t, tc.cached, result.Usage.CacheReadInputTokens) + require.Equal(t, tc.created, result.Usage.CacheCreationInputTokens) + } + count, done := 0, 0 + var last *apicompat.ChatCompletionsChunk + for _, line := range strings.Split(rec.Body.String(), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + payload := strings.TrimPrefix(line, "data: ") + if payload == "[DONE]" { + done++ + if tc.want { + require.NotNil(t, last) + require.NotNil(t, last.Usage) + } + continue + } + require.Zero(t, done, "chunk after DONE") + var chunk apicompat.ChatCompletionsChunk + require.NoError(t, json.Unmarshal([]byte(payload), &chunk)) + last = &chunk + if chunk.Usage == nil { + continue + } + count++ + require.Empty(t, chunk.Choices) + require.Equal(t, tc.prompt, chunk.Usage.PromptTokens) + require.Equal(t, tc.output, chunk.Usage.CompletionTokens) + require.Equal(t, tc.prompt+tc.output, chunk.Usage.TotalTokens) + if tc.cached > 0 { + require.NotNil(t, chunk.Usage.PromptTokensDetails) + require.Equal(t, tc.cached, chunk.Usage.PromptTokensDetails.CachedTokens) + require.Equal(t, tc.created, chunk.Usage.PromptTokensDetails.CacheCreationTokens) + } + } + require.Equal(t, 1, done) + if tc.want { + require.Equal(t, 1, count) + } else { + require.Zero(t, count) + } + }) + } + } + } + } +} diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 32d38cf40da8..687e31cc6f8c 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -42,7 +42,6 @@ func (s *GatewayService) ForwardAsChatCompletions( } originalModel := ccReq.Model clientStream := ccReq.Stream - includeUsage := ccReq.StreamOptions != nil && ccReq.StreamOptions.IncludeUsage // 2. Convert CC → Responses → Anthropic (chained conversion) responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) @@ -185,7 +184,7 @@ func (s *GatewayService) ForwardAsChatCompletions( var result *ForwardResult var handleErr error if clientStream { - result, handleErr = s.handleCCStreamingFromAnthropic(resp, c, originalModel, mappedModel, reasoningEffort, startTime, includeUsage) + result, handleErr = s.handleCCStreamingFromAnthropic(resp, c, originalModel, mappedModel, reasoningEffort, startTime) } else { result, handleErr = s.handleCCBufferedFromAnthropic(resp, c, originalModel, mappedModel, reasoningEffort, startTime) } @@ -358,7 +357,6 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( mappedModel string, reasoningEffort *string, startTime time.Time, - includeUsage bool, ) (*ForwardResult, error) { requestID := resp.Header.Get("x-request-id") @@ -376,7 +374,6 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( anthState.Model = originalModel ccState := apicompat.NewResponsesEventToChatState() ccState.Model = originalModel - ccState.IncludeUsage = includeUsage var usage ClaudeUsage var firstTokenMs *int @@ -467,6 +464,10 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( continue } + // Forward received usage regardless of the client stream_options. + // The intermediate Responses converter synthesizes usage even when absent. + ccState.IncludeUsage = ccState.IncludeUsage || anthropicChatStreamHasUsage(&event, payload) + if processAnthropicEvent(&event) { return resultWithUsage(), nil } @@ -512,3 +513,16 @@ func writeGatewayCCError(c *gin.Context, statusCode int, errType, message string }, }) } + +// anthropicChatStreamHasUsage distinguishes an explicit zero-valued usage object +// from omitted/null usage before the Anthropic→Responses conversion loses that distinction. +func anthropicChatStreamHasUsage(event *apicompat.AnthropicStreamEvent, payload string) bool { + switch event.Type { + case "message_start": + return event.Message != nil && gjson.Get(payload, "message.usage").IsObject() + case "message_delta": + return event.Usage != nil + default: + return false + } +} diff --git a/backend/internal/service/gateway_forward_as_chat_completions_test.go b/backend/internal/service/gateway_forward_as_chat_completions_test.go index f7cb42c69c7c..163b20449030 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions_test.go +++ b/backend/internal/service/gateway_forward_as_chat_completions_test.go @@ -199,7 +199,7 @@ func TestHandleCCStreamingFromAnthropic_CompactSSEFormat(t *testing.T) { } svc := &GatewayService{} - result, err := svc.handleCCStreamingFromAnthropic(resp, c, "k3", "k3", nil, time.Now(), true) + result, err := svc.handleCCStreamingFromAnthropic(resp, c, "k3", "k3", nil, time.Now()) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 21, result.Usage.InputTokens) @@ -236,7 +236,7 @@ func TestHandleCCStreamingFromAnthropic_PreservesMessageStartCacheUsageAndReason } svc := &GatewayService{} - result, err := svc.handleCCStreamingFromAnthropic(resp, c, "gpt-5", "claude-sonnet-4.5", &reasoningEffort, time.Now(), true) + result, err := svc.handleCCStreamingFromAnthropic(resp, c, "gpt-5", "claude-sonnet-4.5", &reasoningEffort, time.Now()) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 20, result.Usage.InputTokens) diff --git a/backend/internal/service/openai_gateway_anthropic_native_pump_test.go b/backend/internal/service/openai_gateway_anthropic_native_pump_test.go index 83bbd4eeff7e..e5b9d0dd84bb 100644 --- a/backend/internal/service/openai_gateway_anthropic_native_pump_test.go +++ b/backend/internal/service/openai_gateway_anthropic_native_pump_test.go @@ -140,7 +140,7 @@ func TestCCStreamingFromNativeAnthropic_HangTimesOut(t *testing.T) { resp, pr, pw := newHangingUpstreamResponse() start := time.Now() - res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start, true) + res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start) _ = pw.Close() _ = pr.Close() @@ -220,7 +220,7 @@ func TestCCStreamingFromNativeAnthropic_HappyPathStillConverts(t *testing.T) { }() defer func() { _ = pr.Close() }() - res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), true) + res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now()) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index bc865af81c31..40b00c81e6ce 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -54,7 +54,6 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( return nil, fmt.Errorf("missing model in request") } clientStream := ccReq.Stream - includeUsage := ccReq.StreamOptions != nil && ccReq.StreamOptions.IncludeUsage // 2. Convert CC → Responses → Anthropic (chained conversion) responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) @@ -136,7 +135,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) if clientStream { - return s.handleCCStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, includeUsage) + return s.handleCCStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) } return s.handleCCBufferedFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) } @@ -303,7 +302,6 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( upstreamModel string, reasoningEffort *string, startTime time.Time, - includeUsage bool, ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") @@ -320,7 +318,6 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( anthState.Model = originalModel ccState := apicompat.NewResponsesEventToChatState() ccState.Model = originalModel - ccState.IncludeUsage = includeUsage var usage ClaudeUsage var firstTokenMs *int @@ -461,6 +458,10 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( continue } + // Forward received usage regardless of the client stream_options. + // The intermediate Responses converter synthesizes usage even when absent. + ccState.IncludeUsage = ccState.IncludeUsage || anthropicChatStreamHasUsage(&event, payload) + if processAnthropicEvent(&event) { return resultWithUsage(), nil } From ece1ecccca842585c99bc738bb93115bd1455e3e Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 21 Sep 2026 12:36:32 +0800 Subject: [PATCH 02/42] fix(gateway): normalize streamed Anthropic usage --- .../anthropic_chat_stream_usage_test.go | 27 +++-- .../gateway_forward_as_chat_completions.go | 5 + .../service/gateway_forward_as_responses.go | 67 ++++++++--- .../gateway_forward_as_responses_test.go | 110 ++++++++++++++++++ ...teway_chat_completions_anthropic_native.go | 5 + .../service/openai_gateway_cn_fixes_test.go | 80 +++++++++++++ ...enai_gateway_responses_anthropic_native.go | 6 + 7 files changed, 277 insertions(+), 23 deletions(-) diff --git a/backend/internal/service/anthropic_chat_stream_usage_test.go b/backend/internal/service/anthropic_chat_stream_usage_test.go index 99ce4d278954..ff1558b073be 100644 --- a/backend/internal/service/anthropic_chat_stream_usage_test.go +++ b/backend/internal/service/anthropic_chat_stream_usage_test.go @@ -22,19 +22,28 @@ func TestAnthropicChatStreamAuthoritativeUsage(t *testing.T) { for _, adapter := range []string{"anthropic", "native"} { for _, options := range []string{"", `,"stream_options":{"include_usage":true}`, `,"stream_options":{"include_usage":false}`} { for _, tc := range []struct { - name, start, delta string - want bool - prompt, output, cached, created int + name, start, delta, repeatDelta string + want bool + prompt, billableInput, totalInput, output, cached, created int }{ - {"cache", `,"usage":{"input_tokens":308,"cache_read_input_tokens":241,"cache_creation_input_tokens":17}`, `,"usage":{"output_tokens":49}`, true, 566, 49, 241, 17}, - {"zero_start", `,"usage":{"input_tokens":0,"output_tokens":0}`, "", true, 0, 0, 0, 0}, - {"zero_delta", "", `,"usage":{"input_tokens":0,"output_tokens":0}`, true, 0, 0, 0, 0}, - {"absent", "", "", false, 0, 0, 0, 0}, - {"null", `,"usage":null`, `,"usage":null`, false, 0, 0, 0, 0}, + {"cache", `,"usage":{"input_tokens":308,"cache_read_input_tokens":241,"cache_creation_input_tokens":17}`, `,"usage":{"output_tokens":49}`, "", true, 566, 308, 566, 49, 241, 17}, + {"deepseek_partial_cache", "", `,"usage":{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}`, "", true, 1200, 400, 1200, 30, 800, 0}, + {"openai_partial_cache", "", `,"usage":{"prompt_tokens":1200,"output_tokens":30,"prompt_tokens_details":{"cached_tokens":800}}`, "", true, 1200, 400, 1200, 30, 800, 0}, + {"repeated_cache_bucket", `,"usage":{"input_tokens":1200}`, `,"usage":{"output_tokens":30,"cache_read_input_tokens":800}`, `,"usage":{"output_tokens":30,"cache_read_input_tokens":800}`, true, 1200, 400, 1200, 30, 800, 0}, + {"kimi_full_cache", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"cache_read_input_tokens":173306}`, "", true, 173306, 0, 173306, 49, 173306, 0}, + {"kimi_full_cache_cached_tokens", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"cached_tokens":173306}`, "", true, 173306, 0, 173306, 49, 173306, 0}, + {"kimi_full_cache_prompt_details", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"prompt_tokens_details":{"cached_tokens":173306}}`, "", true, 173306, 0, 173306, 49, 173306, 0}, + {"zero_start", `,"usage":{"input_tokens":0,"output_tokens":0}`, "", "", true, 0, 0, 0, 0, 0, 0}, + {"zero_delta", "", `,"usage":{"input_tokens":0,"output_tokens":0}`, "", true, 0, 0, 0, 0, 0, 0}, + {"absent", "", "", "", false, 0, 0, 0, 0, 0, 0}, + {"null", `,"usage":null`, `,"usage":null`, "", false, 0, 0, 0, 0, 0, 0}, } { for _, terminal := range []string{"stop", "eof"} { t.Run(adapter+"/"+options+"/"+tc.name+"/"+terminal, func(t *testing.T) { sse := fmt.Sprintf("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"k3\",\"content\":[]%s}}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}%s}\n\n", tc.start, tc.delta) + if tc.repeatDelta != "" { + sse += fmt.Sprintf("event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}%s}\n\n", tc.repeatDelta) + } if terminal == "stop" { // Repeated terminal events and EOF must not duplicate usage. sse += "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" @@ -52,6 +61,7 @@ func TestAnthropicChatStreamAuthoritativeUsage(t *testing.T) { svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} result, err := svc.forwardChatCompletionsViaNativeAnthropic(context.Background(), c, nativeAnthropicTestAccount(), body, "") require.NoError(t, err) + require.Equal(t, tc.totalInput, result.Usage.InputTokens) require.Equal(t, tc.output, result.Usage.OutputTokens) require.Equal(t, tc.cached, result.Usage.CacheReadInputTokens) require.Equal(t, tc.created, result.Usage.CacheCreationInputTokens) @@ -60,6 +70,7 @@ func TestAnthropicChatStreamAuthoritativeUsage(t *testing.T) { account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk-test"}} result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, nil) require.NoError(t, err) + require.Equal(t, tc.billableInput, result.Usage.InputTokens) require.Equal(t, tc.output, result.Usage.OutputTokens) require.Equal(t, tc.cached, result.Usage.CacheReadInputTokens) require.Equal(t, tc.created, result.Usage.CacheCreationInputTokens) diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 687e31cc6f8c..0936af2b4605 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -430,6 +430,11 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( mergeAnthropicUsage(&usage, event.Message.Usage) } + // Keep the outward Responses/Chat usage on the same normalized buckets used + // for billing, including converter handlers that consume event usage. + syncAnthropicResponsesUsage(anthState, usage) + normalizeAnthropicEventUsageForResponses(event, usage) + // Chain: Anthropic event → Responses events → CC chunks responsesEvents := apicompat.AnthropicEventToResponsesEvents(event, anthState) for _, resEvt := range responsesEvents { diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 1b97a739616d..6903e659ad17 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -279,22 +279,22 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { return } + cacheReadTokens := src.CacheReadInputTokens + if cacheReadTokens == 0 && src.CachedTokens > 0 { + cacheReadTokens = src.CachedTokens + } + if cacheReadTokens == 0 && src.PromptTokensDetails != nil && src.PromptTokensDetails.CachedTokens > 0 { + cacheReadTokens = src.PromptTokensDetails.CachedTokens + } + if cacheReadTokens == 0 && src.PromptCacheHitTokens != nil { + cacheReadTokens = max(*src.PromptCacheHitTokens, 0) + } + // Some Anthropic-compatible providers retain OpenAI-style prompt/cache // fields. Prefer those authoritative totals or hit/miss buckets over the // overloaded input_tokens field. This covers Kimi's changing stream // semantics as well as GLM/DeepSeek cache aliases. if src.PromptTokens > 0 || src.PromptCacheHitTokens != nil || src.PromptCacheMissTokens != nil { - cacheReadTokens := src.CacheReadInputTokens - if cacheReadTokens == 0 && src.CachedTokens > 0 { - cacheReadTokens = src.CachedTokens - } - if cacheReadTokens == 0 && src.PromptTokensDetails != nil && src.PromptTokensDetails.CachedTokens > 0 { - cacheReadTokens = src.PromptTokensDetails.CachedTokens - } - if cacheReadTokens == 0 && src.PromptCacheHitTokens != nil { - cacheReadTokens = max(*src.PromptCacheHitTokens, 0) - } - if src.PromptCacheMissTokens != nil { dst.InputTokens = max(*src.PromptCacheMissTokens, 0) } else { @@ -303,13 +303,21 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { dst.CacheReadInputTokens = cacheReadTokens dst.CacheCreationInputTokens = src.CacheCreationInputTokens } else { + previousCacheReadTokens := dst.CacheReadInputTokens + previousCacheCreationTokens := dst.CacheCreationInputTokens if src.InputTokens > 0 { dst.InputTokens = src.InputTokens } - if src.CacheReadInputTokens > 0 { - dst.CacheReadInputTokens = src.CacheReadInputTokens - } else if src.CachedTokens > 0 { - dst.CacheReadInputTokens = src.CachedTokens + if src.InputTokens == 0 && dst.InputTokens > 0 && (cacheReadTokens > 0 || src.CacheCreationInputTokens > 0) { + // Some compatible streams put the total prompt count in message_start, + // then provide only cumulative cache buckets in message_delta. Subtract + // only newly observed buckets so repeated deltas are idempotent. + newCacheReadTokens := max(cacheReadTokens-previousCacheReadTokens, 0) + newCacheCreationTokens := max(src.CacheCreationInputTokens-previousCacheCreationTokens, 0) + dst.InputTokens = max(dst.InputTokens-newCacheReadTokens-newCacheCreationTokens, 0) + } + if cacheReadTokens > 0 { + dst.CacheReadInputTokens = cacheReadTokens } if src.CacheCreationInputTokens > 0 { dst.CacheCreationInputTokens = src.CacheCreationInputTokens @@ -320,6 +328,29 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { } } +func syncAnthropicResponsesUsage(state *apicompat.AnthropicEventToResponsesState, usage ClaudeUsage) { + state.InputTokens = usage.InputTokens + state.OutputTokens = usage.OutputTokens + state.CacheReadInputTokens = usage.CacheReadInputTokens + state.CacheCreationInputTokens = usage.CacheCreationInputTokens +} + +func normalizeAnthropicEventUsageForResponses(event *apicompat.AnthropicStreamEvent, usage ClaudeUsage) { + normalize := func(dst *apicompat.AnthropicUsage) { + if dst == nil { + return + } + dst.InputTokens = usage.InputTokens + dst.OutputTokens = usage.OutputTokens + dst.CacheReadInputTokens = usage.CacheReadInputTokens + dst.CacheCreationInputTokens = usage.CacheCreationInputTokens + } + normalize(event.Usage) + if event.Message != nil { + normalize(&event.Message.Usage) + } +} + // parseAnthropicSSEField parses an SSE field line in the form "field:value" or "field: value". // According to the SSE spec (https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation), // the space after the colon is optional. This function handles both formats. @@ -545,6 +576,12 @@ func (s *GatewayService) handleResponsesStreamingResponse( mergeAnthropicUsage(&usage, event.Message.Usage) } + // Keep the terminal Responses usage aligned with the normalized billing + // buckets. Normalize the converter input too, so message handlers cannot + // restore the provider's overlapping raw input total. + syncAnthropicResponsesUsage(state, usage) + normalizeAnthropicEventUsageForResponses(event, usage) + // Convert to Responses events events := apicompat.AnthropicEventToResponsesEvents(event, state) for _, evt := range events { diff --git a/backend/internal/service/gateway_forward_as_responses_test.go b/backend/internal/service/gateway_forward_as_responses_test.go index 972c6bc1a60e..353eeb88468f 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -307,6 +307,116 @@ func TestHandleResponsesStreamingResponse_PreservesMessageStartCacheUsage(t *tes require.Contains(t, rec.Body.String(), `response.completed`) } +func TestHandleResponsesStreamingResponse_NormalizesTerminalUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + startUsage string + deltaUsage string + wantInput int + wantOutput int + wantCached int + wantCacheCreation int + }{ + { + name: "cache_read_input_tokens full cache", + startUsage: `"input_tokens":173306`, + deltaUsage: `"output_tokens":8,"cache_read_input_tokens":173306`, + wantOutput: 8, + wantCached: 173306, + }, + { + name: "cached_tokens full cache", + startUsage: `"input_tokens":173306`, + deltaUsage: `"output_tokens":8,"cached_tokens":173306`, + wantOutput: 8, + wantCached: 173306, + }, + { + name: "prompt_tokens_details full cache", + startUsage: `"input_tokens":173306`, + deltaUsage: `"output_tokens":8,"prompt_tokens_details":{"cached_tokens":173306}`, + wantOutput: 8, + wantCached: 173306, + }, + { + name: "DeepSeek input total with hit and miss buckets", + startUsage: `"input_tokens":0`, + deltaUsage: `"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400`, + wantInput: 400, + wantOutput: 30, + wantCached: 800, + }, + { + name: "OpenAI prompt total with cached details", + startUsage: `"input_tokens":0`, + deltaUsage: `"prompt_tokens":1200,"output_tokens":30,"prompt_tokens_details":{"cached_tokens":800}`, + wantInput: 400, + wantOutput: 30, + wantCached: 800, + }, + { + name: "cache creation only without prompt total or miss bucket", + startUsage: `"input_tokens":1200`, + deltaUsage: `"output_tokens":30,"cache_creation_input_tokens":800`, + wantInput: 400, + wantOutput: 30, + wantCacheCreation: 800, + }, + } + + for _, tt := range tests { + for _, terminal := range []string{"message_stop", "eof"} { + t.Run(tt.name+"/"+terminal, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + lines := []string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_usage","type":"message","role":"assistant","content":[],"model":"k3","stop_reason":"","usage":{` + tt.startUsage + `}}}`, + ``, + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{` + tt.deltaUsage + `}}`, + ``, + } + if terminal == "message_stop" { + lines = append(lines, `event: message_stop`, `data: {"type":"message_stop"}`, ``) + } + resp := &http.Response{Body: io.NopCloser(strings.NewReader(strings.Join(lines, "\n")))} + + result, err := (&GatewayService{}).handleResponsesStreamingResponse(resp, c, "k3", "k3", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + require.NoError(t, err) + require.Equal(t, tt.wantInput, result.Usage.InputTokens) + require.Equal(t, tt.wantCached, result.Usage.CacheReadInputTokens) + require.Equal(t, tt.wantCacheCreation, result.Usage.CacheCreationInputTokens) + + var completed apicompat.ResponsesStreamEvent + for _, line := range strings.Split(rec.Body.String(), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + var event apicompat.ResponsesStreamEvent + require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event)) + if event.Type == "response.completed" { + completed = event + } + } + require.NotNil(t, completed.Response) + require.NotNil(t, completed.Response.Usage) + require.Equal(t, tt.wantInput+tt.wantCached+tt.wantCacheCreation, completed.Response.Usage.InputTokens) + require.Equal(t, tt.wantOutput, completed.Response.Usage.OutputTokens) + require.Equal(t, completed.Response.Usage.InputTokens+tt.wantOutput, completed.Response.Usage.TotalTokens) + require.Equal(t, tt.wantCacheCreation, completed.Response.Usage.CacheCreationInputTokens) + if tt.wantCached == 0 { + require.Nil(t, completed.Response.Usage.InputTokensDetails) + } else { + require.Equal(t, tt.wantCached, completed.Response.Usage.InputTokensDetails.CachedTokens) + } + }) + } + } +} + func TestParseAnthropicSSEField(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index 40b00c81e6ce..03c8b8e72971 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -407,6 +407,11 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( mergeAnthropicUsage(&usage, event.Message.Usage) } + // Keep the outward Responses/Chat usage on the same normalized buckets used + // for billing, including converter handlers that consume event usage. + syncAnthropicResponsesUsage(anthState, usage) + normalizeAnthropicEventUsageForResponses(event, usage) + // 客户端已断开:跳过转换与写出,继续读上游直到流结束(usage 完整、 // 连接及时归还),不再提前 return。 if clientDisconnected { diff --git a/backend/internal/service/openai_gateway_cn_fixes_test.go b/backend/internal/service/openai_gateway_cn_fixes_test.go index 0dfb7c35b6f0..470c789c7459 100644 --- a/backend/internal/service/openai_gateway_cn_fixes_test.go +++ b/backend/internal/service/openai_gateway_cn_fixes_test.go @@ -11,9 +11,12 @@ package service import ( "context" + "encoding/json" "errors" + "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -109,6 +112,83 @@ func TestResponsesStreamingFromNativeAnthropic_ClientDisconnectDrainsUsage(t *te "output_tokens 必须来自排水读到的末尾 message_delta(断开即弃时会是 1)") } +func TestResponsesStreamingFromNativeAnthropic_NormalizesTerminalUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + startUsage string + deltaUsage string + repeatDelta bool + wantInput int + wantOutput int + wantCached int + wantCacheCreation int + }{ + {name: "full cache", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_read_input_tokens":1200`, wantOutput: 30, wantCached: 1200}, + {name: "partial cache", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_read_input_tokens":800`, wantInput: 400, wantOutput: 30, wantCached: 800}, + {name: "cache creation", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_creation_input_tokens":800`, wantInput: 400, wantOutput: 30, wantCacheCreation: 800}, + {name: "repeated cumulative cache bucket", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_read_input_tokens":800`, repeatDelta: true, wantInput: 400, wantOutput: 30, wantCached: 800}, + } + + for _, tt := range tests { + for _, terminal := range []string{"message_stop", "eof"} { + t.Run(tt.name+"/"+terminal, func(t *testing.T) { + lines := []string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_usage","type":"message","role":"assistant","content":[],"model":"k3","stop_reason":"","usage":{` + tt.startUsage + `}}}`, + ``, + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{` + tt.deltaUsage + `}}`, + ``, + } + if tt.repeatDelta { + lines = append(lines, `event: message_delta`, `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{`+tt.deltaUsage+`}}`, ``) + } + if terminal == "message_stop" { + lines = append(lines, `event: message_stop`, `data: {"type":"message_stop"}`, ``) + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(strings.Join(lines, "\n")))} + + result, err := (&OpenAIGatewayService{}).handleResponsesStreamingFromNativeAnthropic( + resp, c, "k3", "k3", "k3", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + require.NoError(t, err) + require.Equal(t, tt.wantInput+tt.wantCached+tt.wantCacheCreation, result.Usage.InputTokens) + require.Equal(t, tt.wantOutput, result.Usage.OutputTokens) + require.Equal(t, tt.wantCached, result.Usage.CacheReadInputTokens) + require.Equal(t, tt.wantCacheCreation, result.Usage.CacheCreationInputTokens) + + var completed apicompat.ResponsesStreamEvent + for _, line := range strings.Split(rec.Body.String(), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + var event apicompat.ResponsesStreamEvent + require.NoError(t, json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event)) + if event.Type == "response.completed" { + completed = event + } + } + require.NotNil(t, completed.Response) + require.NotNil(t, completed.Response.Usage) + require.Equal(t, tt.wantInput+tt.wantCached+tt.wantCacheCreation, completed.Response.Usage.InputTokens) + require.Equal(t, tt.wantOutput, completed.Response.Usage.OutputTokens) + require.Equal(t, completed.Response.Usage.InputTokens+tt.wantOutput, completed.Response.Usage.TotalTokens) + require.Equal(t, tt.wantCacheCreation, completed.Response.Usage.CacheCreationInputTokens) + if tt.wantCached == 0 { + require.Nil(t, completed.Response.Usage.InputTokensDetails) + } else { + require.Equal(t, tt.wantCached, completed.Response.Usage.InputTokensDetails.CachedTokens) + } + }) + } + } +} + func TestHandle403_CNProviderHTMLBodySkipsAccountPenalty(t *testing.T) { for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { repo := &rateLimitAccountRepoStub{} diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index fa2e190b9704..c5f8ef6887c6 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -397,6 +397,12 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( mergeAnthropicUsage(&usage, event.Message.Usage) } + // Keep terminal Responses usage aligned with the normalized billing + // buckets. Normalize converter input too so raw overlapping totals cannot + // overwrite the state when message_start/message_delta handlers run. + syncAnthropicResponsesUsage(state, usage) + normalizeAnthropicEventUsageForResponses(event, usage) + events := apicompat.AnthropicEventToResponsesEvents(event, state) if clientDisconnected { return From 0416c9ede47e8fbab4a705dc3eeb8b21bf928d73 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 23 Sep 2026 15:27:53 +0800 Subject: [PATCH 03/42] fix(gateway): avoid ambiguous late-cache input subtraction --- .../anthropic_chat_stream_usage_test.go | 9 +++++---- .../service/gateway_forward_as_responses.go | 15 +++++---------- .../gateway_forward_as_responses_test.go | 18 +++++++++--------- .../service/openai_gateway_cn_fixes_test.go | 8 ++++---- 4 files changed, 23 insertions(+), 27 deletions(-) diff --git a/backend/internal/service/anthropic_chat_stream_usage_test.go b/backend/internal/service/anthropic_chat_stream_usage_test.go index ff1558b073be..2397a5802dda 100644 --- a/backend/internal/service/anthropic_chat_stream_usage_test.go +++ b/backend/internal/service/anthropic_chat_stream_usage_test.go @@ -29,10 +29,11 @@ func TestAnthropicChatStreamAuthoritativeUsage(t *testing.T) { {"cache", `,"usage":{"input_tokens":308,"cache_read_input_tokens":241,"cache_creation_input_tokens":17}`, `,"usage":{"output_tokens":49}`, "", true, 566, 308, 566, 49, 241, 17}, {"deepseek_partial_cache", "", `,"usage":{"input_tokens":1200,"output_tokens":30,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":400}`, "", true, 1200, 400, 1200, 30, 800, 0}, {"openai_partial_cache", "", `,"usage":{"prompt_tokens":1200,"output_tokens":30,"prompt_tokens_details":{"cached_tokens":800}}`, "", true, 1200, 400, 1200, 30, 800, 0}, - {"repeated_cache_bucket", `,"usage":{"input_tokens":1200}`, `,"usage":{"output_tokens":30,"cache_read_input_tokens":800}`, `,"usage":{"output_tokens":30,"cache_read_input_tokens":800}`, true, 1200, 400, 1200, 30, 800, 0}, - {"kimi_full_cache", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"cache_read_input_tokens":173306}`, "", true, 173306, 0, 173306, 49, 173306, 0}, - {"kimi_full_cache_cached_tokens", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"cached_tokens":173306}`, "", true, 173306, 0, 173306, 49, 173306, 0}, - {"kimi_full_cache_prompt_details", `,"usage":{"input_tokens":173306}`, `,"usage":{"output_tokens":49,"prompt_tokens_details":{"cached_tokens":173306}}`, "", true, 173306, 0, 173306, 49, 173306, 0}, + {"repeated_authoritative_cache_bucket", `,"usage":{"input_tokens":1200,"prompt_tokens":1200}`, `,"usage":{"output_tokens":30,"prompt_tokens":1200,"cache_read_input_tokens":800}`, `,"usage":{"output_tokens":30,"prompt_tokens":1200,"cache_read_input_tokens":800}`, true, 1200, 400, 1200, 30, 800, 0}, + {"independent_input_with_late_cache", `,"usage":{"input_tokens":308,"cache_read_input_tokens":241,"cache_creation_input_tokens":17}`, `,"usage":{"input_tokens":0,"output_tokens":49,"cache_read_input_tokens":300,"cache_creation_input_tokens":17}`, "", true, 625, 308, 625, 49, 300, 17}, + {"kimi_full_cache", `,"usage":{"input_tokens":173306,"prompt_tokens":173306}`, `,"usage":{"input_tokens":0,"output_tokens":49,"prompt_tokens":173306,"cache_read_input_tokens":173306}`, "", true, 173306, 0, 173306, 49, 173306, 0}, + {"kimi_full_cache_cached_tokens", `,"usage":{"input_tokens":173306,"prompt_tokens":173306}`, `,"usage":{"input_tokens":0,"output_tokens":49,"prompt_tokens":173306,"cached_tokens":173306}`, "", true, 173306, 0, 173306, 49, 173306, 0}, + {"kimi_full_cache_prompt_details", `,"usage":{"input_tokens":173306,"prompt_tokens":173306}`, `,"usage":{"input_tokens":0,"output_tokens":49,"prompt_tokens":173306,"prompt_tokens_details":{"cached_tokens":173306}}`, "", true, 173306, 0, 173306, 49, 173306, 0}, {"zero_start", `,"usage":{"input_tokens":0,"output_tokens":0}`, "", "", true, 0, 0, 0, 0, 0, 0}, {"zero_delta", "", `,"usage":{"input_tokens":0,"output_tokens":0}`, "", true, 0, 0, 0, 0, 0, 0}, {"absent", "", "", "", false, 0, 0, 0, 0, 0, 0}, diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 6903e659ad17..0c976838c7b9 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -303,19 +303,14 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { dst.CacheReadInputTokens = cacheReadTokens dst.CacheCreationInputTokens = src.CacheCreationInputTokens } else { - previousCacheReadTokens := dst.CacheReadInputTokens - previousCacheCreationTokens := dst.CacheCreationInputTokens + // Without an authoritative prompt total or miss bucket, input_tokens is + // provider-specific: it may already be the uncached bucket, or it may be + // a total from an earlier event. Do not infer a subtraction merely because + // a later event contains cache buckets; that would corrupt providers whose + // stream uses independent input and cache fields. if src.InputTokens > 0 { dst.InputTokens = src.InputTokens } - if src.InputTokens == 0 && dst.InputTokens > 0 && (cacheReadTokens > 0 || src.CacheCreationInputTokens > 0) { - // Some compatible streams put the total prompt count in message_start, - // then provide only cumulative cache buckets in message_delta. Subtract - // only newly observed buckets so repeated deltas are idempotent. - newCacheReadTokens := max(cacheReadTokens-previousCacheReadTokens, 0) - newCacheCreationTokens := max(src.CacheCreationInputTokens-previousCacheCreationTokens, 0) - dst.InputTokens = max(dst.InputTokens-newCacheReadTokens-newCacheCreationTokens, 0) - } if cacheReadTokens > 0 { dst.CacheReadInputTokens = cacheReadTokens } diff --git a/backend/internal/service/gateway_forward_as_responses_test.go b/backend/internal/service/gateway_forward_as_responses_test.go index 353eeb88468f..cafee5a71b0c 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -321,22 +321,22 @@ func TestHandleResponsesStreamingResponse_NormalizesTerminalUsage(t *testing.T) }{ { name: "cache_read_input_tokens full cache", - startUsage: `"input_tokens":173306`, - deltaUsage: `"output_tokens":8,"cache_read_input_tokens":173306`, + startUsage: `"input_tokens":173306,"prompt_tokens":173306`, + deltaUsage: `"input_tokens":0,"output_tokens":8,"prompt_tokens":173306,"cache_read_input_tokens":173306`, wantOutput: 8, wantCached: 173306, }, { name: "cached_tokens full cache", - startUsage: `"input_tokens":173306`, - deltaUsage: `"output_tokens":8,"cached_tokens":173306`, + startUsage: `"input_tokens":173306,"prompt_tokens":173306`, + deltaUsage: `"input_tokens":0,"output_tokens":8,"prompt_tokens":173306,"cached_tokens":173306`, wantOutput: 8, wantCached: 173306, }, { name: "prompt_tokens_details full cache", - startUsage: `"input_tokens":173306`, - deltaUsage: `"output_tokens":8,"prompt_tokens_details":{"cached_tokens":173306}`, + startUsage: `"input_tokens":173306,"prompt_tokens":173306`, + deltaUsage: `"input_tokens":0,"output_tokens":8,"prompt_tokens":173306,"prompt_tokens_details":{"cached_tokens":173306}`, wantOutput: 8, wantCached: 173306, }, @@ -357,10 +357,10 @@ func TestHandleResponsesStreamingResponse_NormalizesTerminalUsage(t *testing.T) wantCached: 800, }, { - name: "cache creation only without prompt total or miss bucket", + name: "cache creation only without prompt total or miss bucket keeps independent input", startUsage: `"input_tokens":1200`, - deltaUsage: `"output_tokens":30,"cache_creation_input_tokens":800`, - wantInput: 400, + deltaUsage: `"input_tokens":0,"output_tokens":30,"cache_creation_input_tokens":800`, + wantInput: 1200, wantOutput: 30, wantCacheCreation: 800, }, diff --git a/backend/internal/service/openai_gateway_cn_fixes_test.go b/backend/internal/service/openai_gateway_cn_fixes_test.go index 470c789c7459..da532536b179 100644 --- a/backend/internal/service/openai_gateway_cn_fixes_test.go +++ b/backend/internal/service/openai_gateway_cn_fixes_test.go @@ -125,10 +125,10 @@ func TestResponsesStreamingFromNativeAnthropic_NormalizesTerminalUsage(t *testin wantCached int wantCacheCreation int }{ - {name: "full cache", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_read_input_tokens":1200`, wantOutput: 30, wantCached: 1200}, - {name: "partial cache", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_read_input_tokens":800`, wantInput: 400, wantOutput: 30, wantCached: 800}, - {name: "cache creation", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_creation_input_tokens":800`, wantInput: 400, wantOutput: 30, wantCacheCreation: 800}, - {name: "repeated cumulative cache bucket", startUsage: `"input_tokens":1200`, deltaUsage: `"output_tokens":30,"cache_read_input_tokens":800`, repeatDelta: true, wantInput: 400, wantOutput: 30, wantCached: 800}, + {name: "full cache", startUsage: `"input_tokens":1200,"prompt_tokens":1200`, deltaUsage: `"input_tokens":0,"output_tokens":30,"prompt_tokens":1200,"cache_read_input_tokens":1200`, wantOutput: 30, wantCached: 1200}, + {name: "partial cache", startUsage: `"input_tokens":1200,"prompt_tokens":1200`, deltaUsage: `"input_tokens":0,"output_tokens":30,"prompt_tokens":1200,"cache_read_input_tokens":800`, wantInput: 400, wantOutput: 30, wantCached: 800}, + {name: "cache creation", startUsage: `"input_tokens":1200,"prompt_tokens":1200`, deltaUsage: `"input_tokens":0,"output_tokens":30,"prompt_tokens":1200,"cache_creation_input_tokens":800`, wantInput: 400, wantOutput: 30, wantCacheCreation: 800}, + {name: "repeated cumulative cache bucket", startUsage: `"input_tokens":1200,"prompt_tokens":1200`, deltaUsage: `"input_tokens":0,"output_tokens":30,"prompt_tokens":1200,"cache_read_input_tokens":800`, repeatDelta: true, wantInput: 400, wantOutput: 30, wantCached: 800}, } for _, tt := range tests { From 13fcfc0f3b841198afba71141d280768b04eefb5 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sat, 19 Sep 2026 20:50:28 +0800 Subject: [PATCH 04/42] fix(gateway): resolve composite routes for websocket aliases --- backend/cmd/server/wire_gen.go | 2 +- .../handler/openai_gateway_handler.go | 34 +++- .../handler/openai_gateway_handler_test.go | 43 ++++- .../openai_gateway_ws_composite_test.go | 159 ++++++++++++++++++ backend/internal/handler/wire.go | 2 + .../service/openai_ws_forwarder_ingress.go | 7 +- docs/COMPOSITE_GROUPS.md | 7 + 7 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 backend/internal/handler/openai_gateway_ws_composite_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 37390f92cff7..7adddf9ad453 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -294,7 +294,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService) coordinator := securityaudit.NewCoordinator(legacyEngine, promptService) gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator) - openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator) + openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator, compositeRouteResolver) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService) totpHandler := handler.NewTotpHandler(totpService) passkeyRepository := repository.NewPasskeyRepository(db) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index bf4563f2f207..bfb447593142 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -34,6 +34,7 @@ import ( // OpenAIGatewayHandler handles OpenAI API gateway requests type OpenAIGatewayHandler struct { + compositeResolver *service.CompositeRouteResolver gatewayService *service.OpenAIGatewayService billingCacheService *service.BillingCacheService apiKeyService *service.APIKeyService @@ -2391,7 +2392,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return } // 分组级模型白名单:首帧校验客户端模型,不通过则关闭连接并标记运维原因。 - // 必须在 ensureCompositeTargetPlatform(合成路由改写)之前执行。 + // 必须在合成路由解析和上游模型映射之前执行。 // 与 HTTP 准入一致:帧内重复 model 键/大小写变体可能被上游按末值绑定, // 全部候选值逐一校验,任一未命中即拒绝。 if blocked := blockedModelAllowlistCandidate(apiKey.Group, requestmodel.FromBodyCandidates("", "application/json", firstMessage)); blocked != "" { @@ -2400,7 +2401,21 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked)) return } - ensureCompositeTargetPlatform(c, apiKey, reqModel) + // Keep the client model in the frame for admission, response identity and usage. + // Apply the resolved upstream model through MapRequestModel on every turn. + wsRouteModel := reqModel + if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite { + decision, resolveErr := h.compositeResolver.Resolve(c.Request.Context(), apiKey.Group.ID, reqModel, service.CompositeRouteEndpointResponses) + if resolveErr != nil { + reqLog.Error("openai.websocket_composite_route_failed", zap.Error(resolveErr)) + closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "Failed to resolve composite model route") + return + } + if decision.Matched { + c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision)) + wsRouteModel = decision.UpstreamModel + } + } ctx = c.Request.Context() if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite { platform, ok := service.ResolvedTargetPlatformFromContext(ctx) @@ -2464,8 +2479,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { } // 解析渠道级模型映射 - channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel) - wsForwardModel := openAIChannelForwardModel(channelMappingWS, reqModel) + channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, wsRouteModel) + wsForwardModel := openAIChannelForwardModel(channelMappingWS, wsRouteModel) var currentUserRelease func() var currentAccountRelease func() @@ -2856,7 +2871,16 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { model = reqModel } setOpsRequestContext(c, model, true) - mapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, model) + routeModel := model + if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite { + // The account, target platform and route context are connection-scoped. + // A different public model needs a new connection and fresh resolution. + if model != reqModel { + return "", newOpenAIWSUnsupportedModelSwitchError(model) + } + routeModel = wsRouteModel + } + mapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, routeModel) mappedModelUnchanged := false if previous := turnChannelMapping.Load(); previous != nil && previous.turn < turn { mappedModelUnchanged = strings.TrimSpace(previous.mapping.MappedModel) == strings.TrimSpace(mapping.MappedModel) diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index d13ac015892c..cf9f1d9e7c65 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -1931,7 +1931,10 @@ func newOpenAIWSHandlerTestServer(t *testing.T, h *OpenAIGatewayHandler, subject type openAIResponsesWSUsageLogCase struct { simpleModeRejectAtRead int64 + compositeResolver *service.CompositeRouteResolver + accountPlatform string closeReason string + closeStatus coderws.StatusCode firstPayload string // midPayload 在首个 turn 完成后发送(如 session.update),上游桩会为它 // 回一个 response.completed,客户端按普通事件读取。 @@ -2878,6 +2881,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU upstreamErrCh := make(chan error, 1) var channelSvc *service.ChannelService upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tc.accountPlatform == service.PlatformGrok { + payload, err := io.ReadAll(r.Body) + if err != nil { + upstreamErrCh <- err + return + } + upstreamPayloadCh <- payload + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_grok_test\",\"model\":%q,\"usage\":{\"input_tokens\":2,\"output_tokens\":1}}}\n\n", gjson.GetBytes(payload, "model").String()) + return + } conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{ CompressionMode: coderws.CompressionContextTakeover, }) @@ -2945,6 +2959,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU "openai_apikey_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough, }, } + if tc.accountPlatform != "" { + account.Platform = tc.accountPlatform + } if strings.TrimSpace(tc.ingressMode) != "" { account.Extra["openai_apikey_responses_websockets_v2_mode"] = tc.ingressMode } @@ -3000,7 +3017,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU service.NewBillingService(cfg, nil), nil, billingCacheSvc, - nil, + &compositeWSHTTPUpstream{}, &service.DeferredService{}, nil, nil, @@ -3021,6 +3038,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU } h := &OpenAIGatewayHandler{ cfg: cfg, + compositeResolver: tc.compositeResolver, gatewayService: gatewaySvc, billingCacheService: billingCacheSvc, apiKeyService: &service.APIKeyService{}, @@ -3069,6 +3087,12 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU cancelWrite() require.NoError(t, err) + if tc.closeReason == "" { + tc.closeReason = "not available for this group" + } + if tc.closeStatus == 0 { + tc.closeStatus = coderws.StatusPolicyViolation + } if tc.firstFrameCloseExpected { readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) _, _, readErr := clientConn.Read(readCtx) @@ -3076,12 +3100,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU require.Error(t, readErr, "first frame should have been rejected with a close") var closeErr coderws.CloseError require.ErrorAs(t, readErr, &closeErr) - require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code) + status := tc.closeStatus + if status == 0 { + status = coderws.StatusPolicyViolation + } + require.Equal(t, status, closeErr.Code) reason := tc.closeReason if reason == "" { reason = "not available for this group" } require.Contains(t, closeErr.Reason, reason) + require.Empty(t, upstreamPayloadCh, "rejected first frame must not reach upstream") _ = clientConn.CloseNow() return openAIResponsesWSUsageLogResult{} } @@ -3115,12 +3144,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU require.Error(t, readErr, "second turn should have been rejected with a close") var closeErr coderws.CloseError require.ErrorAs(t, readErr, &closeErr) - require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code) + status := tc.closeStatus + if status == 0 { + status = coderws.StatusPolicyViolation + } + require.Equal(t, status, closeErr.Code) reason := tc.closeReason if reason == "" { reason = "not available for this group" } require.Contains(t, closeErr.Reason, reason) + require.Len(t, upstreamPayloadCh, turnCount-1, "rejected turn must not reach upstream") _ = clientConn.CloseNow() return openAIResponsesWSUsageLogResult{} } @@ -3149,6 +3183,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU } } + if tc.accountPlatform == service.PlatformGrok { + upstreamErrCh <- nil + } select { case upstreamErr := <-upstreamErrCh: require.NoError(t, upstreamErr) diff --git a/backend/internal/handler/openai_gateway_ws_composite_test.go b/backend/internal/handler/openai_gateway_ws_composite_test.go new file mode 100644 index 000000000000..7c2d79bbaaa2 --- /dev/null +++ b/backend/internal/handler/openai_gateway_ws_composite_test.go @@ -0,0 +1,159 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + coderws "github.com/coder/websocket" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type compositeWSRouteRepo struct { + service.CompositeModelRouteRepository + routes []service.CompositeModelRoute + err error +} + +func (r *compositeWSRouteRepo) ListByGroup(context.Context, int64, bool) ([]service.CompositeModelRoute, error) { + return r.routes, r.err +} + +type compositeWSHTTPUpstream struct{ service.HTTPUpstream } + +func (*compositeWSHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return http.DefaultClient.Do(req) +} + +func compositeWSResolver(platform, endpoint, upstream string) *service.CompositeRouteResolver { + return service.NewCompositeRouteResolver(&compositeWSRouteRepo{routes: []service.CompositeModelRoute{{ + GroupID: 4201, PublicModel: "public-alias", MatchType: service.CompositeRouteMatchExact, + TargetPlatform: platform, Endpoint: endpoint, UpstreamModel: upstream, Enabled: true, + }}}) +} +func compositeWSGroup(models ...string) *service.Group { + g := wsAllowlistGroup(len(models) > 0, models...) + g.Platform = service.PlatformComposite + return g +} + +func TestOpenAIResponsesWebSocket_CompositeAlias(t *testing.T) { + for _, platform := range []string{service.PlatformOpenAI, service.PlatformGrok} { + for _, endpoint := range []string{service.CompositeRouteEndpointResponses, service.CompositeRouteEndpointAny} { + for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} { + t.Run(platform+"/"+endpoint+"/"+mode, func(t *testing.T) { + upstream := "gpt-5.4" + if platform == service.PlatformGrok { + upstream = "grok-4.3" + } + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"public-alias","input":"hi"}`, + secondPayload: `{"type":"response.create","input":"again"}`, + group: compositeWSGroup("public-alias"), accountPlatform: platform, ingressMode: mode, + compositeResolver: compositeWSResolver(platform, endpoint, upstream), + }) + require.Len(t, got.upstreamPayloads, 2) + for i, payload := range got.upstreamPayloads { + require.Equal(t, upstream, gjson.GetBytes(payload, "model").String()) + require.Equal(t, "public-alias", gjson.GetBytes(got.clientEvents[i], "response.model").String()) + require.Equal(t, "public-alias", got.logs[i].RequestedModel) + require.NotNil(t, got.logs[i].UpstreamModel) + require.Equal(t, upstream, *got.logs[i].UpstreamModel) + } + }) + } + } + } +} + +func TestOpenAIResponsesWebSocket_CompositeRouteRejections(t *testing.T) { + for _, tc := range []struct { + name, platform, endpoint, model, reason string + repoErr error + status coderws.StatusCode + }{ + {name: "disallowed platform", platform: service.PlatformAnthropic, endpoint: "responses", model: "public-alias", reason: "only supports OpenAI-compatible"}, + {name: "wrong endpoint", platform: service.PlatformOpenAI, endpoint: "messages", model: "public-alias", reason: "only supports OpenAI-compatible"}, + {name: "unknown alias", platform: service.PlatformOpenAI, endpoint: "responses", model: "unknown-alias", reason: "only supports OpenAI-compatible"}, + {name: "resolver error", model: "gpt-5.4", repoErr: errors.New("database unavailable"), reason: "Failed to resolve composite model route", status: coderws.StatusInternalError}, + } { + t.Run(tc.name, func(t *testing.T) { + resolver := compositeWSResolver(tc.platform, tc.endpoint, "gpt-5.4") + if tc.repoErr != nil { + resolver = service.NewCompositeRouteResolver(&compositeWSRouteRepo{err: tc.repoErr}) + } + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"` + tc.model + `"}`, group: compositeWSGroup(), compositeResolver: resolver, + firstFrameCloseExpected: true, closeReason: tc.reason, closeStatus: tc.status, + }) + }) + } +} + +func TestOpenAIResponsesWebSocket_CompositeAdmissionUsesPublicModel(t *testing.T) { + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"public-alias"}`, group: compositeWSGroup("gpt-5.4"), + compositeResolver: compositeWSResolver(service.PlatformOpenAI, "responses", "gpt-5.4"), firstFrameCloseExpected: true, + }) +} + +func TestOpenAIResponsesWebSocket_CompositeModelSwitchRequiresReconnect(t *testing.T) { + for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} { + for _, model := range []string{"gpt-5.4", "grok-4.3", "unknown-alias"} { + t.Run(mode+"/"+model, func(t *testing.T) { + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"public-alias"}`, + secondPayload: `{"type":"response.create","model":"` + model + `"}`, group: compositeWSGroup(), ingressMode: mode, + compositeResolver: compositeWSResolver(service.PlatformOpenAI, "responses", "gpt-5.4"), + secondTurnCloseExpected: true, closeReason: "model switch requires reconnect", + }) + }) + } + } +} + +func TestOpenAIResponsesWebSocket_CompositeChannelBilling(t *testing.T) { + for _, source := range []string{service.BillingModelSourceRequested, service.BillingModelSourceChannelMapped, service.BillingModelSourceUpstream} { + t.Run(source, func(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.6-sol"}`, + secondPayload: `{"type":"response.create","model":"gpt-5.6-sol"}`, + group: compositeWSGroup("gpt-5.6-sol"), + compositeResolver: service.NewCompositeRouteResolver(&compositeWSRouteRepo{routes: []service.CompositeModelRoute{{PublicModel: "gpt-5.6-sol", MatchType: service.CompositeRouteMatchExact, TargetPlatform: service.PlatformOpenAI, Endpoint: service.CompositeRouteEndpointResponses, UpstreamModel: "route-target"}}}), + channelMapping: map[string]string{"route-target": "gpt-5.4"}, billingModelSource: source, + accountModelMapping: map[string]any{"gpt-5.4": "gpt-5.4"}, + }) + for i, log := range got.logs { + require.Equal(t, "gpt-5.4", gjson.GetBytes(got.upstreamPayloads[i], "model").String()) + require.Equal(t, "gpt-5.6-sol", log.RequestedModel) + require.Equal(t, "gpt-5.6-sol", log.Model) + if source == service.BillingModelSourceRequested { + require.InDelta(t, 40e-6, log.TotalCost, 1e-12) + } else { + require.InDelta(t, 20e-6, log.TotalCost, 1e-12) + } + } + }) + } +} + +func TestOpenAIResponsesWebSocket_CompositeDetectorFallback(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.4"}`, group: compositeWSGroup(), + compositeResolver: service.NewCompositeRouteResolver(&compositeWSRouteRepo{}), + }) + require.Equal(t, "gpt-5.4", gjson.GetBytes(got.upstreamFirstPayload, "model").String()) +} + +func TestOpenAIResponsesWebSocket_CompositeSessionModelSwitchRequiresReconnect(t *testing.T) { + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"public-alias"}`, + midPayload: `{"type":"session.update","session":{"model":"grok-4.3"}}`, + secondPayload: `{"type":"response.create"}`, group: compositeWSGroup(), + compositeResolver: compositeWSResolver(service.PlatformOpenAI, "responses", "gpt-5.4"), + secondTurnCloseExpected: true, closeReason: "model switch requires reconnect", + }) +} diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index d35245ecbf95..e51b105728e8 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -130,10 +130,12 @@ func ProvideOpenAIGatewayHandler( grokQuotaService *service.GrokQuotaService, cfg *config.Config, coordinator *securityaudit.Coordinator, + compositeResolver *service.CompositeRouteResolver, ) *OpenAIGatewayHandler { gatewayService.SetPluginManager(pluginManager) h := NewOpenAIGatewayHandler(gatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, cfg) + h.compositeResolver = compositeResolver h.securityAuditCoordinator = coordinator h.grokMediaEligibilityProber = grokQuotaService return h diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index ca6135352bef..98e6327c3ce7 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -585,7 +585,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( bridgeAccountFailoverInputExists := false for turn := 1; ; turn++ { if turn > 1 && hooks != nil && hooks.BeforeRequest != nil { - if err := hooks.BeforeRequest(turn, currentBridgePayload.payloadRaw, currentBridgePayload.originalModel); err != nil { + if err := hooks.BeforeRequest(turn, currentBridgePayload.rawForHash, currentBridgePayload.originalModel); err != nil { return err } } @@ -1272,6 +1272,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } currentPayload := firstPayload.payloadRaw + // Admission hooks must see client model candidates before upstream mapping. + currentClientPayload := firstPayload.rawForHash currentOriginalModel := firstPayload.originalModel currentImageBillingModel := firstPayload.imageBillingModel currentImageSizeTier := firstPayload.imageSizeTier @@ -1460,7 +1462,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } for { if turn > 1 && !skipBeforeTurn && hooks != nil && hooks.BeforeRequest != nil { - if err := hooks.BeforeRequest(turn, currentPayload, currentOriginalModel); err != nil { + if err := hooks.BeforeRequest(turn, currentClientPayload, currentOriginalModel); err != nil { return err } } @@ -1941,6 +1943,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } } currentPayload = nextPayload.payloadRaw + currentClientPayload = nextPayload.rawForHash currentOriginalModel = nextPayload.originalModel currentImageBillingModel = nextPayload.imageBillingModel currentImageSizeTier = nextPayload.imageSizeTier diff --git a/docs/COMPOSITE_GROUPS.md b/docs/COMPOSITE_GROUPS.md index bf32c902bc9f..99456f6efcc2 100644 --- a/docs/COMPOSITE_GROUPS.md +++ b/docs/COMPOSITE_GROUPS.md @@ -63,6 +63,13 @@ payloads, and apply the configured `upstream_model` before dispatch. Codex model manifest requests reuse the existing OpenAI account selection and failover path within the Composite group. +Responses WebSocket resolves the first `response.create` model in the +`responses` route domain (including `any` routes). Only OpenAI and Grok targets +are supported. The public model remains the admission and response identity; +the route's upstream model is applied before channel/account mapping. Subsequent +turns can repeat or omit the public model. Changing it requires reconnecting so +the gateway can resolve the route and select an account again. + ## Built-In Detection Composite routing detects common public model IDs and provider-prefixed IDs: From e878ab710f9df9f17f84e453929c8d6ac901d41e Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 21 Sep 2026 11:06:21 +0800 Subject: [PATCH 05/42] fix(gateway): constrain account model route ownership --- .../service/openai_account_scheduler.go | 5 + .../service/openai_account_scheduler_test.go | 114 ++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index c6341d5294e2..ae4e2c23211e 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -1776,6 +1776,11 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatibleReason(ctx con if account == nil { return false, "account_nil" } + if source, ok := CompositeRouteSourceFromContext(ctx); ok && source == CompositeRouteSourceAccount { + if publicModel, modelOK := RequestedPublicModelFromContext(ctx); modelOK && !explicitModelMappingClaims(*account, publicModel) { + return false, "account_model_not_owned" + } + } if req.RequirePrivacySet && !account.IsPrivacySet() { return false, "privacy_not_set" } diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 081cf30f9a95..7a047d6069b1 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -44,6 +44,10 @@ func (r schedulerTestOpenAIAccountRepo) ListSchedulableByGroupIDAndPlatform(ctx return result, nil } +func (r schedulerTestOpenAIAccountRepo) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) { + return append([]Account(nil), r.accounts...), nil +} + func (r schedulerTestOpenAIAccountRepo) ListSchedulableByPlatform(ctx context.Context, platform string) ([]Account, error) { var result []Account for _, acc := range r.accounts { @@ -1257,6 +1261,116 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_StickyWeightedSessionIn } } +func TestOpenAIGatewayService_SelectAccountWithScheduler_AccountModelRouteOnlyUsesClaimingAccount(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(1010711) + owner := Account{ + ID: 371011, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 10, + GroupIDs: []int64{groupID}, + Credentials: map[string]any{"model_mapping": map[string]any{"team-alias": "gpt-5.1"}}, + } + nonOwner := Account{ + ID: 371012, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, + GroupIDs: []int64{groupID}, + } + repo := schedulerTestOpenAIAccountRepo{accounts: []Account{owner, nonOwner}} + ownership, err := (&GatewayService{accountRepo: repo}).resolveCompositeModelOwnership(ctx, groupID, "team-alias") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, ownership) + ctx = WithCompositeRouteDecision(ctx, CompositeRouteDecision{ + Matched: true, Source: CompositeRouteSourceAccount, GroupID: groupID, + PublicModel: "team-alias", TargetPlatform: ownership.TargetPlatform, UpstreamModel: "team-alias", + }) + + for _, tt := range []struct { + name string + sessionHash string + sessionBindings map[string]int64 + }{ + {name: "initial filter excludes non owner"}, + {name: "non owner sticky hit is rejected", sessionHash: "non-owner", sessionBindings: map[string]int64{"openai:non-owner": nonOwner.ID}}, + } { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.Scheduling.LoadBatchEnabled = false + svc := &OpenAIGatewayService{ + accountRepo: repo, + cache: &schedulerTestGatewayCache{sessionBindings: tt.sessionBindings}, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + + selection, _, selectErr := svc.SelectAccountWithScheduler( + ctx, &groupID, "", tt.sessionHash, "team-alias", nil, OpenAIUpstreamTransportAny, false, + ) + require.NoError(t, selectErr) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, owner.ID, selection.Account.ID) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + }) + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_AccountModelRouteDBRecheckFailsOverToOwner(t *testing.T) { + ctx := context.Background() + groupID := int64(1010712) + mapping := map[string]any{"model_mapping": map[string]any{"team-alias": "gpt-5.1"}} + stalePrimary := &Account{ + ID: 371021, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, + GroupIDs: []int64{groupID}, Credentials: mapping, + } + staleBackup := &Account{ + ID: 371022, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 10, + GroupIDs: []int64{groupID}, Credentials: mapping, + } + dbPrimary := *stalePrimary + dbPrimary.Credentials = nil + dbBackup := *staleBackup + repo := schedulerTestOpenAIAccountRepo{accounts: []Account{dbPrimary, dbBackup}} + ctx = WithCompositeRouteDecision(ctx, CompositeRouteDecision{ + Matched: true, Source: CompositeRouteSourceAccount, GroupID: groupID, + PublicModel: "team-alias", TargetPlatform: PlatformOpenAI, UpstreamModel: "team-alias", + }) + acquiredIDs, releasedIDs := []int64{}, []int64{} + svc := &OpenAIGatewayService{ + accountRepo: repo, + cfg: &config.Config{RunMode: config.RunModeStandard}, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + schedulerSnapshot: &SchedulerSnapshotService{cache: &openAISnapshotCacheStub{ + snapshotAccounts: []*Account{stalePrimary, staleBackup}, + accountsByID: map[int64]*Account{stalePrimary.ID: stalePrimary, staleBackup.ID: staleBackup}, + }}, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{ + acquiredIDs: &acquiredIDs, releasedIDs: &releasedIDs, + }), + } + + selection, _, err := svc.SelectAccountWithScheduler( + ctx, &groupID, "", "", "team-alias", nil, OpenAIUpstreamTransportAny, false, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, dbBackup.ID, selection.Account.ID) + if containsInt64(acquiredIDs, dbPrimary.ID) { + require.Contains(t, releasedIDs, dbPrimary.ID) + } + require.Contains(t, acquiredIDs, dbBackup.ID) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + func TestOpenAIGatewayService_SelectAccountWithScheduler_StickyWeightedPreviousRequiresMovableContext(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() From 453b796df0bb2e1bf20f04f709ce8acb7f1480ba Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 23 Sep 2026 15:27:26 +0800 Subject: [PATCH 06/42] fix(gateway): enforce composite model ownership in legacy scheduling --- .../service/openai_account_scheduler_test.go | 123 ++++++++++++++++++ .../service/openai_gateway_scheduling.go | 10 ++ 2 files changed, 133 insertions(+) diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 7a047d6069b1..13959d5df786 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -3927,3 +3927,126 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SubscriptionPriorityWai require.Equal(t, int64(38011), selection.WaitPlan.AccountID) require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) } + +func TestOpenAIGatewayService_SelectAccountWithScheduler_LegacyAccountModelRouteExcludesNonOwner(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(1010713) + owner := Account{ + ID: 371031, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 10, + GroupIDs: []int64{groupID}, + Credentials: map[string]any{"model_mapping": map[string]any{"team-alias": "gpt-5.1"}}, + // WS transport must be resolvable for the websocket sub-case so the + // ownership filter, not transport admission, is the behavior under test. + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + nonOwner := Account{ + ID: 371032, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, + GroupIDs: []int64{groupID}, + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + repo := schedulerTestOpenAIAccountRepo{accounts: []Account{owner, nonOwner}} + ownership, err := (&GatewayService{accountRepo: repo}).resolveCompositeModelOwnership(ctx, groupID, "team-alias") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, ownership) + ctx = WithCompositeRouteDecision(ctx, CompositeRouteDecision{ + Matched: true, Source: CompositeRouteSourceAccount, GroupID: groupID, + PublicModel: "team-alias", TargetPlatform: ownership.TargetPlatform, UpstreamModel: "team-alias", + }) + + for _, tt := range []struct { + name string + requiredTransport OpenAIUpstreamTransport + sessionHash string + sessionBindings map[string]int64 + }{ + {name: "initial filter excludes non owner", requiredTransport: OpenAIUpstreamTransportAny}, + {name: "initial filter excludes non owner for websocket transport", requiredTransport: OpenAIUpstreamTransportResponsesWebsocket}, + {name: "non owner sticky hit is rejected", requiredTransport: OpenAIUpstreamTransportAny, sessionHash: "non-owner", sessionBindings: map[string]int64{"openai:non-owner": nonOwner.ID}}, + } { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsockets = true + svc := &OpenAIGatewayService{ + accountRepo: repo, + cache: &schedulerTestGatewayCache{sessionBindings: tt.sessionBindings}, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("false"), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + require.Nil(t, svc.getOpenAIAccountScheduler(ctx), "test precondition: advanced scheduler disabled") + + selection, _, selectErr := svc.SelectAccountWithScheduler( + ctx, &groupID, "", tt.sessionHash, "team-alias", nil, tt.requiredTransport, false, + ) + require.NoError(t, selectErr) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, owner.ID, selection.Account.ID) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + }) + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_LegacyAccountModelRouteDBRecheckFailsOverToOwner(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(1010714) + mapping := map[string]any{"model_mapping": map[string]any{"team-alias": "gpt-5.1"}} + stalePrimary := &Account{ + ID: 371041, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, + GroupIDs: []int64{groupID}, Credentials: mapping, + } + staleBackup := &Account{ + ID: 371042, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 10, + GroupIDs: []int64{groupID}, Credentials: mapping, + } + dbPrimary := *stalePrimary + dbPrimary.Credentials = nil + dbBackup := *staleBackup + repo := schedulerTestOpenAIAccountRepo{accounts: []Account{dbPrimary, dbBackup}} + ctx = WithCompositeRouteDecision(ctx, CompositeRouteDecision{ + Matched: true, Source: CompositeRouteSourceAccount, GroupID: groupID, + PublicModel: "team-alias", TargetPlatform: PlatformOpenAI, UpstreamModel: "team-alias", + }) + acquiredIDs, releasedIDs := []int64{}, []int64{} + cfg := &config.Config{RunMode: config.RunModeStandard} + svc := &OpenAIGatewayService{ + accountRepo: repo, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("false"), + schedulerSnapshot: &SchedulerSnapshotService{cache: &openAISnapshotCacheStub{ + snapshotAccounts: []*Account{stalePrimary, staleBackup}, + accountsByID: map[int64]*Account{stalePrimary.ID: stalePrimary, staleBackup.ID: staleBackup}, + }}, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{ + acquiredIDs: &acquiredIDs, releasedIDs: &releasedIDs, + }), + } + require.Nil(t, svc.getOpenAIAccountScheduler(ctx), "test precondition: advanced scheduler disabled") + + selection, _, err := svc.SelectAccountWithScheduler( + ctx, &groupID, "", "", "team-alias", nil, OpenAIUpstreamTransportAny, false, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, dbBackup.ID, selection.Account.ID) + if containsInt64(acquiredIDs, dbPrimary.ID) { + require.Contains(t, releasedIDs, dbPrimary.ID) + } + require.Contains(t, acquiredIDs, dbBackup.ID) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index 2f9a750caad6..5c5f5db42a71 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -392,6 +392,16 @@ func openAICompatibleAccountEligibilityFailureReasonBeforeProfit(ctx context.Con if account == nil { return "account_nil" } + // account_model composite routes publish an alias that only accounts with an + // explicit model mapping own. Both scheduler modes (advanced and legacy) must + // enforce ownership before any priority/sticky/transport consideration; a + // non-owner would forward the raw public alias and get model_not_found or + // hit an unrelated same-name upstream model. + if source, ok := CompositeRouteSourceFromContext(ctx); ok && source == CompositeRouteSourceAccount { + if publicModel, modelOK := RequestedPublicModelFromContext(ctx); modelOK && !explicitModelMappingClaims(*account, publicModel) { + return "account_model_not_owned" + } + } if account.Platform != platform || !account.IsOpenAICompatible() { return "platform_mismatch" } From e2c35cf394ac91c7e35f14d0fa837f630ca25e3f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 23 Sep 2026 17:47:47 +0800 Subject: [PATCH 07/42] fix(account): prevent whitelist model mapping conflicts --- .../account/BulkEditAccountModal.vue | 1 + .../components/account/CreateAccountModal.vue | 3 ++ .../components/account/EditAccountModal.vue | 8 ++--- .../account/ModelWhitelistSelector.vue | 6 ++++ .../__tests__/EditAccountModal.spec.ts | 19 +++++++++++ .../__tests__/ModelWhitelistSelector.spec.ts | 33 ++++++++++++++++++- .../__tests__/useModelWhitelist.spec.ts | 13 ++++++++ .../src/i18n/locales/en/admin/accounts.ts | 1 + .../src/i18n/locales/zh/admin/accounts.ts | 1 + 9 files changed, 80 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 22f6f3e788e0..7acd3ac79b1a 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -343,6 +343,7 @@ diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index c6616c679f5b..ff0f07d13882 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1518,6 +1518,7 @@
- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ @@ -779,7 +779,7 @@

- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ @@ -991,7 +991,7 @@

- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ @@ -1213,7 +1213,7 @@

- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ t('admin.accounts.supportsAllModels') }} diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue index 5f359ccca1d9..d1e7e557cbba 100644 --- a/frontend/src/components/account/ModelWhitelistSelector.vue +++ b/frontend/src/components/account/ModelWhitelistSelector.vue @@ -159,6 +159,7 @@ const { t } = useI18n() const props = defineProps<{ modelValue: string[] + modelMappings?: { from: string; to: string }[] platform?: string platforms?: string[] accountId?: number @@ -274,6 +275,11 @@ const addCustom = () => { appStore.showInfo(t('admin.accounts.modelExists')) return } + const conflict = props.modelMappings?.find(mapping => mapping.from.trim() === model && mapping.to.trim() && mapping.to.trim() !== model) + if (conflict) { + appStore.showInfo(t('admin.accounts.modelMappingConflict', { from: model, to: conflict.to.trim() })) + return + } emit('update:modelValue', [...props.modelValue, model]) customModel.value = '' } diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 7b104410c1bc..92274571a637 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -70,6 +70,7 @@ const BaseDialogStub = defineComponent({ const ModelWhitelistSelectorStub = defineComponent({ name: 'ModelWhitelistSelector', props: { + modelMappings: { type: Array, default: () => [] }, modelValue: { type: Array, default: () => [] @@ -330,6 +331,24 @@ describe('EditAccountModal', () => { afterEach(() => vi.useRealTimers()) + it('passes existing non-identity mappings to the whitelist selector and preserves them on save', async () => { + const account = buildAccount() + account.credentials.model_mapping = { 'gpt-5.2': 'gpt-5.2', 'gpt-latest': 'deepseek-chat' } + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + const wrapper = mountModal(account) + expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('modelMappings')).toEqual([ + { from: 'gpt-latest', to: 'deepseek-chat' } + ]) + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.model_mapping).toEqual(account.credentials.model_mapping) + await wrapper.setProps({ show: false }) + await wrapper.setProps({ show: true, account: { ...account } }) + expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('modelMappings')).toEqual([ + { from: 'gpt-latest', to: 'deepseek-chat' } + ]) + }) + it('sets expiry presets from now instead of extending the saved expiry', async () => { vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(new Date('2028-02-29T12:34:00')) diff --git a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts index 97667e3ee402..576df0d0b9f3 100644 --- a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts +++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts @@ -24,7 +24,7 @@ vi.mock('vue-i18n', async () => { return { ...actual, useI18n: () => ({ - t: (key: string) => (key === 'common.copy' ? '复制' : key) + t: (key: string, params?: Record) => key === 'common.copy' ? '复制' : key === 'admin.accounts.modelMappingConflict' ? `Model mapping conflict: ${params?.from} → ${params?.to}` : key }) } }) @@ -91,6 +91,37 @@ describe('ModelWhitelistSelector', () => { syncUpstreamModelsPreview.mockReset() }) + it('rejects a custom whitelist model that is already mapped to a different target', async () => { + const wrapper = mountSelector({ modelMappings: [{ from: 'gpt-latest', to: 'deepseek-chat' }] }) + await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue(' gpt-latest ') + await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click') + + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(showInfo).toHaveBeenCalledWith(expect.stringContaining('gpt-latest → deepseek-chat')) + }) + + it('keeps the existing duplicate identity warning before checking mappings', async () => { + const wrapper = mountSelector({ modelValue: ['gpt-latest'], modelMappings: [{ from: 'gpt-latest', to: 'deepseek-chat' }] }) + await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue('gpt-latest') + await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click') + expect(showInfo).toHaveBeenCalledWith('admin.accounts.modelExists') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('allows matching identity mapping as a whitelist model', async () => { + const wrapper = mountSelector({ modelMappings: [{ from: 'gpt-latest', to: 'gpt-latest' }] }) + await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue('gpt-latest') + await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click') + expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-latest']]]) + }) + + it('still allows custom models without a mapping prop', async () => { + const wrapper = mountSelector() + await wrapper.get('input[placeholder="admin.accounts.enterCustomModelName"]').setValue('custom-model') + await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.addModel')!.trigger('click') + expect(wrapper.emitted('update:modelValue')).toEqual([[['custom-model']]]) + }) + it('copies a model ID without selecting the model', async () => { const wrapper = mountSelector() await wrapper.get('div.cursor-pointer').trigger('click') diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index bb73136e0a4b..ef61e6ab4595 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -158,6 +158,19 @@ describe('useModelWhitelist', () => { }) }) + it('combined mode retains a mapping when a whitelist entry has the same source', () => { + expect(buildModelMappingObject('combined', ['gpt-latest'], [{ from: 'gpt-latest', to: 'deepseek-chat' }])).toEqual({ + 'gpt-latest': 'deepseek-chat' + }) + }) + + it('split mapping keeps only identity entries in the whitelist after reopening', () => { + expect(splitModelMappingObject({ 'gpt-latest': 'deepseek-chat', 'gpt-5.4': 'gpt-5.4' })).toEqual({ + allowedModels: ['gpt-5.4'], + modelMappings: [{ from: 'gpt-latest', to: 'deepseek-chat' }] + }) + }) + it('splitModelMappingObject 会把身份映射还原成白名单,其余保留为映射', () => { const parsed = splitModelMappingObject({ 'gpt-5.4': 'gpt-5.4', diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 40fd7b6f0b7d..685ca6c62b43 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -828,6 +828,7 @@ export default { enterCustomModelName: 'Enter custom model name', addModel: 'Add', modelExists: 'Model already exists', + modelMappingConflict: 'A mapping already exists for {from} → {to}. Modify or remove it under Model Mapping before adding this whitelist model', modelCount: '{count} models', poolMode: 'Pool Mode', poolModeHint: 'Enable when upstream is an account pool; errors won\'t mark local account status', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index bc374be2939f..d4309e948590 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -933,6 +933,7 @@ export default { enterCustomModelName: '输入自定义模型名称', addModel: '填入', modelExists: '该模型已存在', + modelMappingConflict: '该模型已配置映射 {from} → {to},请在模型映射中修改或删除后再添加白名单模型', modelCount: '{count} 个模型', poolMode: '池模式', poolModeHint: '上游为账号池时启用,错误不标记本地账号状态', From b31b435091709920c87c2b5d4d66bf9e8d25134b Mon Sep 17 00:00:00 2001 From: feeeei Date: Thu, 24 Sep 2026 16:20:16 +0800 Subject: [PATCH 08/42] =?UTF-8?q?fix(gateway):=20Claude=20Code=20=E9=99=90?= =?UTF-8?q?=E5=88=B6=E5=88=86=E7=BB=84=E7=9A=84=20OpenAI=20=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E5=85=A5=E5=8F=A3=E6=94=B9=E8=B5=B0=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E5=88=86=E7=BB=84,=E4=B8=8D=E5=86=8D=E7=9B=B4=E6=8E=A5=20403?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分组开启 claude_code_only 并配置降级分组时,/v1/messages 会在选号入口的 checkClaudeCodeRestriction 中把非 Claude Code 请求解析到降级分组;但 GatewayHandler.ChatCompletions / Responses 在 handler 层只要分组开了 claude_code_only 就直接返回 403,不看降级分组,请求到不了调度层:只会发 Chat Completions / Responses 的第三方客户端拿这类分组的 Key 一律 403。 - handler 层提前拒绝只在未配置降级分组时生效,拒绝的状态码与文案不变 - 配置了降级分组时请求继续,由 SelectAccountWithLoadAwareness 解析到降级分组 选号,再经已有的 Chat Completions / Responses ⇄ Anthropic Messages 转换转发; service 层无改动 - 与 /v1/messages 的降级语义一致:选号与渠道限制预检按降级分组,计费与用量 记录仍按 API Key 所属分组 新增 handler 单测覆盖两个入口在有/无降级分组时的行为。 --- .../gateway_handler_chat_completions.go | 7 +- ...teway_handler_claude_code_fallback_test.go | 181 ++++++++++++++++++ .../handler/gateway_handler_responses.go | 12 +- 3 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 backend/internal/handler/gateway_handler_claude_code_fallback_test.go diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index 7a985045967d..6a5fd0160fd7 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -101,8 +101,11 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { // 解析渠道级模型映射 channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) - // Claude Code only restriction - if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly { + // Claude Code only restriction: /v1/chat/completions is never a Claude Code + // endpoint. With a fallback group the request continues and account selection + // (checkClaudeCodeRestriction) schedules it in the fallback group; without one + // it is rejected here. + if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly && apiKey.Group.FallbackGroupID == nil { h.chatCompletionsErrorResponse(c, http.StatusForbidden, "permission_error", "This group is restricted to Claude Code clients (/v1/messages only)") return diff --git a/backend/internal/handler/gateway_handler_claude_code_fallback_test.go b/backend/internal/handler/gateway_handler_claude_code_fallback_test.go new file mode 100644 index 000000000000..ae15cd887d80 --- /dev/null +++ b/backend/internal/handler/gateway_handler_claude_code_fallback_test.go @@ -0,0 +1,181 @@ +//go:build unit + +package handler + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// groupScopedSchedulerCache 按桶的分组只返回该分组的成员账号,并记录被查询过的分组。 +type groupScopedSchedulerCache struct { + *fakeSchedulerCache + mu sync.Mutex + groupIDs []int64 +} + +func (c *groupScopedSchedulerCache) GetSnapshot(_ context.Context, bucket service.SchedulerBucket) ([]*service.Account, bool, error) { + c.mu.Lock() + c.groupIDs = append(c.groupIDs, bucket.GroupID) + c.mu.Unlock() + var members []*service.Account + for _, account := range c.accounts { + for _, ag := range account.AccountGroups { + if ag.GroupID == bucket.GroupID { + members = append(members, account) + break + } + } + } + return members, true, nil +} + +func (c *groupScopedSchedulerCache) queriedGroupIDs() []int64 { + c.mu.Lock() + defer c.mu.Unlock() + return append([]int64(nil), c.groupIDs...) +} + +type groupMapRepo struct { + *fakeGroupRepo + groups map[int64]*service.Group +} + +func (r *groupMapRepo) GetByID(_ context.Context, id int64) (*service.Group, error) { + if group, ok := r.groups[id]; ok { + return group, nil + } + return nil, service.ErrGroupNotFound +} + +func (r *groupMapRepo) GetByIDLite(ctx context.Context, id int64) (*service.Group, error) { + return r.GetByID(ctx, id) +} + +func TestGatewayOpenAICompatibleHandlersClaudeCodeOnlyFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + + const ( + primaryGroupID = int64(9300) + fallbackGroupID = int64(9301) + primaryAccountID = int64(9310) + fallbackAccountID = int64(9311) + ) + + // 账号不带 api_key:转发在取令牌时失败,请求不会触达上游,断言只看选号结果。 + newAccount := func(id, groupID int64) *service.Account { + return &service.Account{ + ID: id, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, + Status: service.StatusActive, Schedulable: true, Concurrency: 1, + AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID}}, + } + } + + endpoints := []struct { + name string + path string + body string + call func(*GatewayHandler, *gin.Context) + }{ + { + name: "responses", path: "/v1/responses", + body: `{"model":"claude-sonnet-4-5","input":"hello","stream":false}`, + call: (*GatewayHandler).Responses, + }, + { + name: "chat completions", path: "/v1/chat/completions", + body: `{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}],"stream":false}`, + call: (*GatewayHandler).ChatCompletions, + }, + } + + for _, ep := range endpoints { + for _, tc := range []struct { + name string + hasFallback bool + }{ + {name: "with fallback group", hasFallback: true}, + {name: "without fallback group", hasFallback: false}, + } { + t.Run(ep.name+"/"+tc.name, func(t *testing.T) { + primary := &service.Group{ + ID: primaryGroupID, Hydrated: true, Platform: service.PlatformAnthropic, + Status: service.StatusActive, ClaudeCodeOnly: true, + } + if tc.hasFallback { + fallbackID := fallbackGroupID + primary.FallbackGroupID = &fallbackID + } + fallback := &service.Group{ + ID: fallbackGroupID, Hydrated: true, Platform: service.PlatformAnthropic, + Status: service.StatusActive, + } + + schedulerCache := &groupScopedSchedulerCache{fakeSchedulerCache: &fakeSchedulerCache{accounts: []*service.Account{ + newAccount(primaryAccountID, primaryGroupID), + newAccount(fallbackAccountID, fallbackGroupID), + }}} + gatewayService := service.NewGatewayService( + nil, &groupMapRepo{fakeGroupRepo: &fakeGroupRepo{}, groups: map[int64]*service.Group{ + primaryGroupID: primary, + fallbackGroupID: fallback, + }}, nil, nil, nil, nil, nil, nil, nil, + service.NewSchedulerSnapshotService(schedulerCache, nil, nil, nil, nil), + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + ) + cfg := &config.Config{RunMode: config.RunModeSimple} + billingCacheService := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil) + t.Cleanup(billingCacheService.Stop) + h := &GatewayHandler{ + gatewayService: gatewayService, + billingCacheService: billingCacheService, + concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(&fakeConcurrencyCache{}), SSEPingFormatClaude, 0), + maxAccountSwitches: 1, + cfg: cfg, + } + + primaryGroupIDRef := primaryGroupID + apiKey := &service.APIKey{ + ID: 9320, UserID: 9330, GroupID: &primaryGroupIDRef, Group: primary, Status: service.StatusActive, + User: &service.User{ID: 9330, Concurrency: 10, Balance: 100}, + } + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + ctx := context.WithValue(context.Background(), ctxkey.Group, primary) + req := httptest.NewRequest(http.MethodPost, ep.path, bytes.NewBufferString(ep.body)).WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + c.Request = req + c.Set(string(middleware.ContextKeyAPIKey), apiKey) + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10}) + + ep.call(h, c) + + selected, reachedSelection := c.Get(opsAccountIDKey) + if !tc.hasFallback { + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Contains(t, recorder.Body.String(), "This group is restricted to Claude Code clients") + require.False(t, reachedSelection, "a Claude Code only group without fallback must be rejected before account selection") + require.Empty(t, schedulerCache.queriedGroupIDs()) + return + } + require.NotContains(t, recorder.Body.String(), "restricted to Claude Code clients") + require.True(t, reachedSelection, "a Claude Code only group with fallback must reach account selection") + require.Equal(t, fallbackAccountID, selected) + queried := schedulerCache.queriedGroupIDs() + require.Contains(t, queried, fallbackGroupID) + require.NotContains(t, queried, primaryGroupID) + }) + } + } +} diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index a1708773154b..1cfb122cc010 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -105,13 +105,11 @@ func (h *GatewayHandler) Responses(c *gin.Context) { // 解析渠道级模型映射 channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(requestCtx, apiKey.GroupID, reqModel) - // Claude Code only restriction: - // /v1/responses is never a Claude Code endpoint. - // When claude_code_only is enabled, this endpoint is rejected. - // The existing service-layer checkClaudeCodeRestriction handles degradation - // to fallback groups when the Forward path calls SelectAccountForModelWithExclusions. - // Here we just reject at handler level since /v1/responses clients can't be Claude Code. - if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly { + // Claude Code only restriction: /v1/responses is never a Claude Code + // endpoint. With a fallback group the request continues and account selection + // (checkClaudeCodeRestriction) schedules it in the fallback group; without one + // it is rejected here. + if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly && apiKey.Group.FallbackGroupID == nil { h.responsesErrorResponse(c, http.StatusForbidden, "permission_error", "This group is restricted to Claude Code clients (/v1/messages only)") return From 9a62841fd124d026cf3694fcf9b79e98addcdbdc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:09:17 +0000 Subject: [PATCH 09/42] chore: sync VERSION to 0.2.9 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index a45be4627678..1866a362b7be 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.8 +0.2.9 From bd48b2b2d2f037ef56e13ab246d912ce38aa901a Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 28 Sep 2026 12:15:27 +0800 Subject: [PATCH 10/42] fix(antigravity): keep compat streams alive before first content --- .../antigravity_gateway_compat_stream.go | 111 ++++++-- .../antigravity_gateway_compat_test.go | 241 ++++++++++++++++++ 2 files changed, 336 insertions(+), 16 deletions(-) diff --git a/backend/internal/service/antigravity_gateway_compat_stream.go b/backend/internal/service/antigravity_gateway_compat_stream.go index 1a69bea601b3..140792451d63 100644 --- a/backend/internal/service/antigravity_gateway_compat_stream.go +++ b/backend/internal/service/antigravity_gateway_compat_stream.go @@ -107,15 +107,23 @@ type antigravityCompatScanEvent struct { } type antigravityCompatStreamSession struct { - processor *antigravity.StreamingProcessor - adapter antigravityCompatStreamAdapter - writer *antigravityClientWriter - usage *ClaudeUsage - pendingEvents []apicompat.AnthropicStreamEvent - firstTokenMs *int - startTime time.Time - meaningfulData bool -} + processor *antigravity.StreamingProcessor + adapter antigravityCompatStreamAdapter + writer *antigravityClientWriter + usage *ClaudeUsage + pendingEvents []apicompat.AnthropicStreamEvent + firstTokenMs *int + startTime time.Time + meaningfulData bool + preContentKeepaliveSent bool + preContentKeepaliveInterval time.Duration +} + +const ( + antigravityCompatPreContentKeepaliveInterval = 15 * time.Second + // Allow slow first tokens, but do not let comment-only streams reset the idle timer forever. + antigravityCompatPreContentMaxWait = 2 * time.Minute +) func newAntigravityCompatStreamSession( model string, @@ -124,11 +132,12 @@ func newAntigravityCompatStreamSession( writer *antigravityClientWriter, ) *antigravityCompatStreamSession { return &antigravityCompatStreamSession{ - processor: antigravity.NewStreamingProcessor(model), - adapter: adapter, - writer: writer, - usage: &ClaudeUsage{}, - startTime: startTime, + processor: antigravity.NewStreamingProcessor(model), + adapter: adapter, + writer: writer, + usage: &ClaudeUsage{}, + startTime: startTime, + preContentKeepaliveInterval: antigravityCompatPreContentKeepaliveInterval, } } @@ -144,11 +153,26 @@ func (s *antigravityCompatStreamSession) hasMeaningfulData() bool { return s.meaningfulData || s.processor.HasContent() } +func (s *antigravityCompatStreamSession) writePreContentKeepalive(now time.Time) { + // Intentional tradeoff: this commits HTTP 200 after 15s, so later upstream + // failures cannot fail over; report them as SSE errors to the client instead. + if s.hasMeaningfulData() || s.writer.Disconnected() || now.Sub(s.startTime) < s.preContentKeepaliveInterval { + return + } + if s.writer.Write([]byte(": ping\n\n")) { + s.preContentKeepaliveSent = true + } +} + func (s *antigravityCompatStreamSession) finish() (*antigravityStreamResult, error) { finalEvents, usage := s.processor.Finish() mergeAntigravityCompatUsage(s.usage, usage) s.consumeClaudeEvents(finalEvents) if !s.hasMeaningfulData() && !s.writer.Disconnected() { + if s.preContentKeepaliveSent { + s.adapter.WriteError(s.writer, "empty_stream") + return s.result(false), errors.New("empty Antigravity compatibility stream after keepalive") + } return nil, antigravityCompatEmptyStreamError() } s.adapter.Finalize(s.writer) @@ -257,6 +281,13 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream( originalModel string, adapter antigravityCompatStreamAdapter, prefix string, +) (*antigravityStreamResult, error) { + return s.handleAntigravityCompatStreamWithKeepaliveInterval(c, resp, startTime, originalModel, adapter, prefix, antigravityCompatPreContentKeepaliveInterval, antigravityCompatPreContentMaxWait) +} + +func (s *AntigravityGatewayService) handleAntigravityCompatStreamWithKeepaliveInterval( + c *gin.Context, resp *http.Response, startTime time.Time, originalModel string, + adapter antigravityCompatStreamAdapter, prefix string, interval, maxPreContentWait time.Duration, ) (*antigravityStreamResult, error) { flusher, ok := c.Writer.(http.Flusher) if !ok { @@ -272,6 +303,7 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream( c.Status(http.StatusOK) } session := newAntigravityCompatStreamSession(originalModel, startTime, adapter, writer) + session.preContentKeepaliveInterval = interval events, stopScanner, maxLineSize := s.startAntigravityCompatScanner(resp.Body) defer stopScanner() @@ -284,13 +316,36 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream( if keepaliveTicker != nil { defer keepaliveTicker.Stop() } + preContentDelay := time.Until(startTime.Add(interval)) + if preContentDelay < 0 { + preContentDelay = 0 + } + preContentTimer := time.NewTimer(preContentDelay) + defer preContentTimer.Stop() + preContentDeadline := startTime.Add(maxPreContentWait) + deadlineDelay := time.Until(preContentDeadline) + if deadlineDelay < 0 { + deadlineDelay = 0 + } + deadlineTimer := time.NewTimer(deadlineDelay) + defer deadlineTimer.Stop() + preContentTimeout := func() (*antigravityStreamResult, error) { + if session.preContentKeepaliveSent { + writeAntigravityCompatStreamError(c, adapter, writer, "stream_timeout") + return session.collectResult(false), fmt.Errorf("pre-content stream timeout") + } + return nil, antigravityCompatEmptyStreamError() + } for { select { case event, open := <-events: + if !session.hasMeaningfulData() && !writer.Disconnected() && !time.Now().Before(preContentDeadline) { + return preContentTimeout() + } if !open { if !session.hasMeaningfulData() && !writer.Disconnected() { - return nil, antigravityCompatEmptyStreamError() + return handleAntigravityCompatEmptyStream(c, session) } return session.finish() } @@ -306,7 +361,7 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream( return session.collectResult(true), nil } if !session.hasMeaningfulData() { - return nil, antigravityCompatEmptyStreamError() + return preContentTimeout() } logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (%s)", prefix) writeAntigravityCompatStreamError(c, adapter, writer, "stream_timeout") @@ -316,10 +371,30 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream( if session.hasMeaningfulData() && !writer.Disconnected() { writer.Write([]byte(": ping\n\n")) } + case now := <-preContentTimer.C: + if !session.hasMeaningfulData() && !writer.Disconnected() && !now.Before(preContentDeadline) { + return preContentTimeout() + } + session.writePreContentKeepalive(now) + if !session.hasMeaningfulData() && !writer.Disconnected() { + preContentTimer.Reset(interval) + } + case <-deadlineTimer.C: + if !session.hasMeaningfulData() && !writer.Disconnected() { + return preContentTimeout() + } } } } +func handleAntigravityCompatEmptyStream(c *gin.Context, session *antigravityCompatStreamSession) (*antigravityStreamResult, error) { + if session.preContentKeepaliveSent { + writeAntigravityCompatStreamError(c, session.adapter, session.writer, "empty_stream") + return session.collectResult(false), errors.New("empty Antigravity compatibility stream after keepalive") + } + return nil, antigravityCompatEmptyStreamError() +} + func (s *AntigravityGatewayService) startAntigravityCompatScanner( body io.Reader, ) (<-chan antigravityCompatScanEvent, func(), int) { @@ -404,6 +479,10 @@ func (s *AntigravityGatewayService) handleAntigravityCompatReadError( prefix string, ) (*antigravityStreamResult, error) { if !session.hasMeaningfulData() && !session.writer.Disconnected() { + if session.preContentKeepaliveSent { + writeAntigravityCompatStreamError(c, session.adapter, session.writer, "stream_read_error") + return session.collectResult(false), fmt.Errorf("stream read error: %w", err) + } return nil, antigravityCompatEmptyStreamError() } if disconnect, handled := handleStreamReadError(err, session.writer.Disconnected(), prefix); handled { diff --git a/backend/internal/service/antigravity_gateway_compat_test.go b/backend/internal/service/antigravity_gateway_compat_test.go index 8912251c410b..825997e4f3b3 100644 --- a/backend/internal/service/antigravity_gateway_compat_test.go +++ b/backend/internal/service/antigravity_gateway_compat_test.go @@ -28,6 +28,20 @@ type antigravityCompatErrorReader struct { err error } +type antigravityCompatNotifyingWriter struct { + gin.ResponseWriter + wrote chan struct{} +} + +func (w *antigravityCompatNotifyingWriter) Write(data []byte) (int, error) { + n, err := w.ResponseWriter.Write(data) + select { + case w.wrote <- struct{}{}: + default: + } + return n, err +} + func (r *antigravityCompatErrorReader) Read(p []byte) (int, error) { if r.off < len(r.data) { n := copy(p, r.data[r.off:]) @@ -786,3 +800,230 @@ func TestAntigravityCompatKeepaliveAfterFirstEvent(t *testing.T) { require.Contains(t, recorder.Header().Get("Content-Type"), "text/event-stream") require.NoError(t, reader.Close()) } + +func TestAntigravityCompatPreContentKeepalive(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + name string + adapter antigravityCompatStreamAdapter + want string + }{ + {"chat completions", newAntigravityChatStreamAdapter("gemini-3.1-pro", false), "data: "}, + {"responses", newAntigravityResponsesStreamAdapter("gemini-3.1-pro"), "event: "}, + } { + t.Run(tt.name, func(t *testing.T) { + c, recorder := newAntigravityCompatContext(http.MethodPost, "/", nil) + writer := newAntigravityClientWriter(c.Writer, c.Writer, "test") + writer.beforeFirstWrite = func() { c.Header("Content-Type", "text/event-stream") } + start := time.Now().Add(-20 * time.Second) + session := newAntigravityCompatStreamSession("gemini-3.1-pro", start, tt.adapter, writer) + session.writePreContentKeepalive(start.Add(14 * time.Second)) + require.Empty(t, recorder.Body.String()) + session.consumeClaudeData("message_start", `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant"}}`) + require.False(t, session.hasMeaningfulData()) + session.writePreContentKeepalive(start.Add(14 * time.Second)) + require.Empty(t, recorder.Body.String()) + session.writePreContentKeepalive(start.Add(15 * time.Second)) + require.Equal(t, ": ping\n\n", recorder.Body.String()) + require.Nil(t, session.firstTokenMs) + session.consumeClaudeData("content_block_delta", `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`) + require.True(t, session.hasMeaningfulData()) + require.NotNil(t, session.firstTokenMs) + require.GreaterOrEqual(t, *session.firstTokenMs, 20000) + require.Contains(t, recorder.Body.String(), tt.want) + require.Greater(t, strings.Index(recorder.Body.String(), tt.want), strings.Index(recorder.Body.String(), ": ping")) + before := recorder.Body.String() + session.writePreContentKeepalive(start.Add(30 * time.Second)) + require.Equal(t, before, recorder.Body.String()) + }) + } +} + +func TestAntigravityCompatHandlerPreContentKeepalive(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + name string + adapter func() antigravityCompatStreamAdapter + line string + want string + }{ + {"silent chat", func() antigravityCompatStreamAdapter { return newAntigravityChatStreamAdapter("gemini-3.1-pro", false) }, "", `"upstream_error"`}, + {"signature-only responses", func() antigravityCompatStreamAdapter { return newAntigravityResponsesStreamAdapter("gemini-3.1-pro") }, `data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"sig","text":""}]}}]}}` + "\n\n", "event: error"}, + } { + t.Run(tt.name, func(t *testing.T) { + svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize, StreamDataIntervalTimeout: 30}, nil) + c, recorder := newAntigravityCompatContext(http.MethodPost, "/", nil) + notifier := &antigravityCompatNotifyingWriter{ResponseWriter: c.Writer, wrote: make(chan struct{}, 1)} + c.Writer = notifier + reader, pipeWriter := io.Pipe() + defer func() { _ = reader.Close() }() + resp := &http.Response{StatusCode: http.StatusOK, Body: reader} + done := make(chan error, 1) + go func() { + _, err := svc.handleAntigravityCompatStream(c, resp, time.Now().Add(-antigravityCompatPreContentKeepaliveInterval+200*time.Millisecond), "gemini-3.1-pro", tt.adapter(), "test") + done <- err + }() + if tt.line != "" { + _, err := io.WriteString(pipeWriter, tt.line) + require.NoError(t, err) + } + select { + case <-notifier.wrote: + case <-time.After(2 * time.Second): + _ = pipeWriter.Close() + t.Fatal("no pre-content keepalive before read timeout") + } + require.Equal(t, ": ping\n\n", recorder.Body.String()) + require.NoError(t, pipeWriter.Close()) + require.Error(t, <-done) + require.Contains(t, recorder.Body.String(), tt.want) + require.True(t, IsResponseCommitted(c)) + }) + } +} + +func TestAntigravityCompatHandlerRepeatsPreContentKeepalive(t *testing.T) { + svc := newAntigravityCompatService(config.GatewayConfig{ + MaxLineSize: defaultMaxLineSize, StreamDataIntervalTimeout: 30, StreamKeepaliveInterval: 0, + }, nil) + c, recorder := newAntigravityCompatContext(http.MethodPost, "/", nil) + reader, pipeWriter := io.Pipe() + defer func() { _ = reader.Close() }() + resp := &http.Response{StatusCode: http.StatusOK, Body: reader} + done := make(chan error, 1) + go func() { + _, err := svc.handleAntigravityCompatStreamWithKeepaliveInterval( + c, resp, time.Now().Add(-15*time.Millisecond), "gemini-3.1-pro", + newAntigravityResponsesStreamAdapter("gemini-3.1-pro"), "test", 15*time.Millisecond, time.Second, + ) + done <- err + }() + time.Sleep(55 * time.Millisecond) + require.NoError(t, pipeWriter.Close()) + require.Error(t, <-done) + require.GreaterOrEqual(t, strings.Count(recorder.Body.String(), ": ping\n\n"), 3) + require.Contains(t, recorder.Body.String(), "event: error") + require.True(t, IsResponseCommitted(c)) +} + +func TestAntigravityCompatHandlerPreContentDeadlineWithCommentOnlyStream(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize, StreamDataIntervalTimeout: 1}, nil) + c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/responses", nil) + reader, pipeWriter := io.Pipe() + defer func() { _ = reader.Close() }() + resp := &http.Response{StatusCode: http.StatusOK, Body: reader} + done := make(chan error, 1) + go func() { + _, err := svc.handleAntigravityCompatStreamWithKeepaliveInterval(c, resp, time.Now(), "gemini-3.1-pro", + newAntigravityResponsesStreamAdapter("gemini-3.1-pro"), "test", 10*time.Millisecond, 100*time.Millisecond) + done <- err + }() + stop := make(chan struct{}) + defer close(stop) + go func() { + ticker := time.NewTicker(5 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + if _, err := io.WriteString(pipeWriter, ": upstream ping\n\n"); err != nil { + return + } + } + } + }() + select { + case err := <-done: + require.ErrorContains(t, err, "pre-content stream timeout") + case <-time.After(time.Second): + t.Fatal("comment-only stream exceeded pre-content deadline") + } + require.Contains(t, recorder.Body.String(), ": ping\n\n") + require.Contains(t, recorder.Body.String(), "event: error") + require.True(t, IsResponseCommitted(c)) + _ = pipeWriter.Close() +} + +func TestAntigravityCompatExpiredPreContentDeadlineDoesNotCommit(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, nil) + c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/responses", nil) + reader, pipeWriter := io.Pipe() + defer func() { _ = reader.Close() }() + defer func() { _ = pipeWriter.Close() }() + resp := &http.Response{StatusCode: http.StatusOK, Body: reader} + result, err := svc.handleAntigravityCompatStreamWithKeepaliveInterval(c, resp, + time.Now().Add(-time.Minute), "gemini-3.1-pro", + newAntigravityResponsesStreamAdapter("gemini-3.1-pro"), "test", 10*time.Millisecond, 20*time.Millisecond) + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Empty(t, recorder.Body.String()) + require.False(t, IsResponseCommitted(c)) +} + +func TestAntigravityCompatHandlerErrorsAfterPreContentPing(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + name string + wait time.Duration + want string + }{ + {"read error", time.Second, "stream_read_error"}, + {"timeout", 25 * time.Millisecond, "stream_timeout"}, + } { + t.Run(tc.name, func(t *testing.T) { + svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, nil) + c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/responses", nil) + reader, pipeWriter := io.Pipe() + defer func() { _ = reader.Close() }() + defer func() { _ = pipeWriter.Close() }() + if tc.name == "read error" { + go func() { + time.Sleep(15 * time.Millisecond) + _ = pipeWriter.CloseWithError(io.ErrUnexpectedEOF) + }() + } + resp := &http.Response{StatusCode: http.StatusOK, Body: reader} + _, err := svc.handleAntigravityCompatStreamWithKeepaliveInterval(c, resp, + time.Now().Add(-20*time.Millisecond), "gemini-3.1-pro", + newAntigravityResponsesStreamAdapter("gemini-3.1-pro"), "test", 10*time.Millisecond, tc.wait) + require.Error(t, err) + require.Contains(t, recorder.Body.String(), ": ping\n\n") + require.Contains(t, recorder.Body.String(), tc.want) + require.True(t, IsResponseCommitted(c)) + }) + } +} + +func TestAntigravityCompatEmptyAfterKeepaliveReportsStreamError(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + name string + adapter antigravityCompatStreamAdapter + want string + }{ + {"chat completions", newAntigravityChatStreamAdapter("gemini-3.1-pro", false), `"upstream_error"`}, + {"responses", newAntigravityResponsesStreamAdapter("gemini-3.1-pro"), "event: error"}, + } { + t.Run(tt.name, func(t *testing.T) { + c, recorder := newAntigravityCompatContext(http.MethodPost, "/", nil) + writer := newAntigravityClientWriter(c.Writer, c.Writer, "test") + writer.beforeFirstWrite = func() { c.Header("Content-Type", "text/event-stream") } + start := time.Now().Add(-20 * time.Second) + session := newAntigravityCompatStreamSession("gemini-3.1-pro", start, tt.adapter, writer) + session.consumeClaudeData("message_start", `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant"}}`) + session.writePreContentKeepalive(start.Add(15 * time.Second)) + result, err := handleAntigravityCompatEmptyStream(c, session) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.NotErrorAs(t, err, &failoverErr) + require.NotNil(t, result) + require.True(t, IsResponseCommitted(c)) + require.Contains(t, recorder.Body.String(), tt.want) + }) + } +} From aa3a3ea7870e7ffaa3c9672a9f14e83114eb3917 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 28 Sep 2026 14:48:42 +0800 Subject: [PATCH 11/42] feat(dashboard): toggle recent usage between tokens and spending --- .../handler/admin/dashboard_handler.go | 6 +++- .../admin/dashboard_handler_cache_test.go | 17 ++++++++++ .../handler/admin/dashboard_query_cache.go | 6 ++-- .../admin/dashboard_snapshot_v2_handler.go | 2 +- .../usage_log_repo_integration_test.go | 23 ++++++++++++- .../repository/usage_log_repo_trend.go | 10 ++++-- backend/internal/server/api_contract_test.go | 2 +- .../internal/service/account_usage_service.go | 2 +- .../account_usage_service_batch_test.go | 2 +- backend/internal/service/dashboard_service.go | 4 +-- frontend/src/api/admin/dashboard.ts | 1 + .../src/i18n/locales/en/admin/overview.ts | 1 + .../src/i18n/locales/zh/admin/overview.ts | 1 + frontend/src/views/admin/DashboardView.vue | 33 +++++++++++++++---- .../admin/__tests__/DashboardView.spec.ts | 28 ++++++++++++++++ 15 files changed, 118 insertions(+), 20 deletions(-) diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index 42d0538c9bb8..22c8e775b136 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -500,7 +500,11 @@ func (h *DashboardHandler) GetUserUsageTrend(c *gin.Context) { limit = 12 } - trend, hit, err := h.getUserUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit) + metric := c.DefaultQuery("metric", "tokens") + if metric != "tokens" && metric != "actual_cost" { + metric = "tokens" + } + trend, hit, err := h.getUserUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, limit, metric) if err != nil { response.Error(c, 500, "Failed to get user usage trend") return diff --git a/backend/internal/handler/admin/dashboard_handler_cache_test.go b/backend/internal/handler/admin/dashboard_handler_cache_test.go index ec8888497982..357d2edf10f8 100644 --- a/backend/internal/handler/admin/dashboard_handler_cache_test.go +++ b/backend/internal/handler/admin/dashboard_handler_cache_test.go @@ -45,6 +45,7 @@ func (r *dashboardUsageRepoCacheProbe) GetUserUsageTrend( startTime, endTime time.Time, granularity string, limit int, + metric string, ) ([]usagestats.UserUsageTrendPoint, error) { r.usersTrendCalls.Add(1) return []usagestats.UserUsageTrendPoint{{ @@ -115,4 +116,20 @@ func TestDashboardHandler_GetUserUsageTrend_UsesCache(t *testing.T) { require.Equal(t, http.StatusOK, rec2.Code) require.Equal(t, "hit", rec2.Header().Get("X-Snapshot-Cache")) require.Equal(t, int32(1), repo.usersTrendCalls.Load()) + + for _, tc := range []struct { + metric, cache string + calls int32 + }{ + {"actual_cost", "miss", 2}, + {"actual_cost", "hit", 2}, + {"tokens", "hit", 2}, + } { + req := httptest.NewRequest(http.MethodGet, "/admin/dashboard/users-trend?start_date=2026-03-01&end_date=2026-03-07&granularity=day&limit=8&metric="+tc.metric, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, tc.cache, rec.Header().Get("X-Snapshot-Cache")) + require.Equal(t, tc.calls, repo.usersTrendCalls.Load()) + } } diff --git a/backend/internal/handler/admin/dashboard_query_cache.go b/backend/internal/handler/admin/dashboard_query_cache.go index 61d38a66e99e..4a6fe4e5fe3c 100644 --- a/backend/internal/handler/admin/dashboard_query_cache.go +++ b/backend/internal/handler/admin/dashboard_query_cache.go @@ -53,6 +53,7 @@ type dashboardEntityTrendCacheKey struct { EndTime string `json:"end_time"` Granularity string `json:"granularity"` Limit int `json:"limit"` + Metric string `json:"metric,omitempty"` } func cacheStatusValue(hit bool) string { @@ -213,15 +214,16 @@ func (h *DashboardHandler) getAPIKeyUsageTrendCached(ctx context.Context, startT return trend, hit, err } -func (h *DashboardHandler) getUserUsageTrendCached(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, bool, error) { +func (h *DashboardHandler) getUserUsageTrendCached(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) ([]usagestats.UserUsageTrendPoint, bool, error) { key := mustMarshalDashboardCacheKey(dashboardEntityTrendCacheKey{ StartTime: startTime.UTC().Format(time.RFC3339), EndTime: endTime.UTC().Format(time.RFC3339), Granularity: granularity, Limit: limit, + Metric: metric, }) entry, hit, err := dashboardUsersTrendCache.GetOrLoad(key, func() (any, error) { - return h.dashboardService.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit) + return h.dashboardService.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit, metric) }) if err != nil { return nil, hit, err diff --git a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go index 1900205eaf6b..5e8b66ac6677 100644 --- a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go +++ b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go @@ -243,7 +243,7 @@ func (h *DashboardHandler) buildSnapshotV2Response( } if includeUsersTrend { - usersTrend, _, err := h.getUserUsageTrendCached(ctx, startTime, endTime, granularity, usersTrendLimit) + usersTrend, _, err := h.getUserUsageTrendCached(ctx, startTime, endTime, granularity, usersTrendLimit, "tokens") if err != nil { return nil, errors.New("failed to get user usage trend") } diff --git a/backend/internal/repository/usage_log_repo_integration_test.go b/backend/internal/repository/usage_log_repo_integration_test.go index b43c6d56ef12..1ba7609fa4e0 100644 --- a/backend/internal/repository/usage_log_repo_integration_test.go +++ b/backend/internal/repository/usage_log_repo_integration_test.go @@ -1577,11 +1577,32 @@ func (s *UsageLogRepoSuite) TestGetUserUsageTrend() { startTime := base.Add(-1 * time.Hour) endTime := base.Add(48 * time.Hour) - trend, err := s.repo.GetUserUsageTrend(s.ctx, startTime, endTime, "day", 10) + trend, err := s.repo.GetUserUsageTrend(s.ctx, startTime, endTime, "day", 10, "tokens") s.Require().NoError(err, "GetUserUsageTrend") s.Require().GreaterOrEqual(len(trend), 2) } +func (s *UsageLogRepoSuite) TestGetUserUsageTrend_SelectsTopByMetric() { + highTokens := mustCreateUser(s.T(), s.client, &service.User{Email: "tokens@test.com"}) + highSpend := mustCreateUser(s.T(), s.client, &service.User{Email: "spend@test.com"}) + tokenKey := mustCreateApiKey(s.T(), s.client, &service.APIKey{UserID: highTokens.ID, Key: "sk-trend-tokens", Name: "tokens"}) + spendKey := mustCreateApiKey(s.T(), s.client, &service.APIKey{UserID: highSpend.ID, Key: "sk-trend-spend", Name: "spend"}) + account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "acc-trend-metric"}) + at := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) + s.createUsageLog(highTokens, tokenKey, account, 1000, 0, 0.1, at) + s.createUsageLog(highSpend, spendKey, account, 10, 0, 5, at) + start, end := at.Add(-time.Hour), at.Add(time.Hour) + tokens, err := s.repo.GetUserUsageTrend(s.ctx, start, end, "day", 1, "tokens") + s.Require().NoError(err) + s.Require().Len(tokens, 1) + s.Require().Equal(highTokens.ID, tokens[0].UserID) + spend, err := s.repo.GetUserUsageTrend(s.ctx, start, end, "day", 1, "actual_cost") + s.Require().NoError(err) + s.Require().Len(spend, 1) + s.Require().Equal(highSpend.ID, spend[0].UserID) + s.Require().Equal(5.0, spend[0].ActualCost) +} + // --- GetAPIKeyUsageTrend --- func (s *UsageLogRepoSuite) TestGetAPIKeyUsageTrend() { diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go index 0b1bbe7d912d..cc7a46ffbf1d 100644 --- a/backend/internal/repository/usage_log_repo_trend.go +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -82,8 +82,12 @@ func (r *usageLogRepository) GetAPIKeyUsageTrend(ctx context.Context, startTime, } // GetUserUsageTrend returns usage trend data grouped by user and date -func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) (results []UserUsageTrendPoint, err error) { +func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) (results []UserUsageTrendPoint, err error) { dateFormat := safeDateFormat(granularity) + rankExpr := "SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens)" + if metric == "actual_cost" { + rankExpr = "SUM(actual_cost)" + } query := fmt.Sprintf(` WITH top_users AS ( @@ -91,7 +95,7 @@ func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, e FROM usage_logs WHERE created_at >= $1 AND created_at < $2 GROUP BY user_id - ORDER BY SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) DESC + ORDER BY %s DESC, user_id ASC LIMIT $3 ) SELECT @@ -109,7 +113,7 @@ func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, e AND u.created_at >= $4 AND u.created_at < $5 GROUP BY date, u.user_id, us.email, us.username ORDER BY date ASC, tokens DESC - `, dateFormat) + `, rankExpr, dateFormat) rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit, startTime, endTime) if err != nil { diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 219c83b149bf..c156073c5f12 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -2652,7 +2652,7 @@ func (r *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, e return nil, errors.New("not implemented") } -func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) { +func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) ([]usagestats.UserUsageTrendPoint, error) { return nil, errors.New("not implemented") } diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 3347aa328db0..746cb324a24b 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -53,7 +53,7 @@ type UsageLogRepository interface { GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) - GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) + GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) ([]usagestats.UserUsageTrendPoint, error) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (*usagestats.UserSpendingRankingResponse, error) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) diff --git a/backend/internal/service/account_usage_service_batch_test.go b/backend/internal/service/account_usage_service_batch_test.go index b4eb32ed27bf..06b9f10e00d8 100644 --- a/backend/internal/service/account_usage_service_batch_test.go +++ b/backend/internal/service/account_usage_service_batch_test.go @@ -76,7 +76,7 @@ func (r *usageBatchLogRepoStub) GetAllGroupUsageSummary(context.Context, time.Ti func (r *usageBatchLogRepoStub) GetAPIKeyUsageTrend(context.Context, time.Time, time.Time, string, int) ([]usagestats.APIKeyUsageTrendPoint, error) { return nil, nil } -func (r *usageBatchLogRepoStub) GetUserUsageTrend(context.Context, time.Time, time.Time, string, int) ([]usagestats.UserUsageTrendPoint, error) { +func (r *usageBatchLogRepoStub) GetUserUsageTrend(context.Context, time.Time, time.Time, string, int, string) ([]usagestats.UserUsageTrendPoint, error) { return nil, nil } func (r *usageBatchLogRepoStub) GetUserSpendingRanking(context.Context, time.Time, time.Time, int) (*usagestats.UserSpendingRankingResponse, error) { diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go index 3c656b61f5d7..f0bb2b9f6ed0 100644 --- a/backend/internal/service/dashboard_service.go +++ b/backend/internal/service/dashboard_service.go @@ -392,8 +392,8 @@ func (s *DashboardService) GetAPIKeyUsageTrend(ctx context.Context, startTime, e return trend, nil } -func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) { - trend, err := s.usageRepo.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit) +func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int, metric string) ([]usagestats.UserUsageTrendPoint, error) { + trend, err := s.usageRepo.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit, metric) if err != nil { return nil, fmt.Errorf("get user usage trend: %w", err) } diff --git a/frontend/src/api/admin/dashboard.ts b/frontend/src/api/admin/dashboard.ts index 92d9861d4f9d..b50be760f320 100644 --- a/frontend/src/api/admin/dashboard.ts +++ b/frontend/src/api/admin/dashboard.ts @@ -235,6 +235,7 @@ export async function getApiKeyUsageTrend( export interface UserTrendParams extends TrendParams { limit?: number + metric?: 'tokens' | 'actual_cost' } export interface UserTrendResponse { diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 97f9ae9553af..b2d33b20c9bb 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -50,6 +50,7 @@ export default { accountCost: 'Cost', noDataAvailable: 'No data available', recentUsage: 'Recent Usage', + actualSpending: 'Actual spending ($)', viewModelDistribution: 'Model Distribution', viewSpendingRanking: 'User Spending Ranking', spendingRankingTitle: 'User Spending Ranking', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 1e8a9887c580..56638d4bc489 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -50,6 +50,7 @@ export default { tokens: 'Token', cache: '缓存', recentUsage: '最近使用', + actualSpending: '实际消费 ($)', viewModelDistribution: '模型分布', viewSpendingRanking: '用户消费榜', spendingRankingTitle: '用户消费榜', diff --git a/frontend/src/views/admin/DashboardView.vue b/frontend/src/views/admin/DashboardView.vue index 210d17ea46a4..061f9e6f4bc9 100644 --- a/frontend/src/views/admin/DashboardView.vue +++ b/frontend/src/views/admin/DashboardView.vue @@ -318,9 +318,16 @@

-

- {{ t('admin.dashboard.recentUsage') }} (Top 12) -

+
+

{{ t('admin.dashboard.recentUsage') }} (Top 12)

+
+ +
+
@@ -401,6 +408,7 @@ const rankingError = ref(false) const trendData = ref([]) const modelStats = ref([]) const userTrend = ref([]) +const userTrendMetric = ref<'tokens' | 'actual_cost'>('tokens') const rankingItems = ref([]) const rankingTotalActualCost = ref(0) const rankingTotalRequests = ref(0) @@ -476,7 +484,7 @@ const lineOptions = computed(() => ({ }, callbacks: { label: (context: any) => { - return `${context.dataset.label}: ${formatTokens(context.raw)}` + return `${context.dataset.label}: ${formatUserTrendValue(Number(context.raw))}` } } } @@ -502,7 +510,7 @@ const lineOptions = computed(() => ({ font: { size: 10 }, - callback: (value: string | number) => formatTokens(Number(value)) + callback: (value: string | number) => formatUserTrendValue(Number(value)) } } } @@ -536,7 +544,7 @@ const userTrendChartData = computed(() => { if (!userGroups.has(key)) { userGroups.set(key, { name: getDisplayName(point), data: new Map() }) } - userGroups.get(key)!.data.set(point.date, point.tokens) + userGroups.get(key)!.data.set(point.date, userTrendMetric.value === 'tokens' ? point.tokens : point.actual_cost) }) const sortedDates = Array.from(allDates).sort() @@ -571,6 +579,9 @@ const userTrendChartData = computed(() => { }) // Format helpers +const formatUserTrendValue = (value: number): string => + userTrendMetric.value === 'tokens' ? formatTokens(value) : `$${formatCost(value)}` + const formatTokens = (value: number | undefined): string => { if (value === undefined || value === null) return '0' if (value >= 1_000_000_000) { @@ -679,6 +690,13 @@ const loadDashboardSnapshot = async (includeStats: boolean) => { } } +const setUserTrendMetric = (metric: 'tokens' | 'actual_cost') => { + if (userTrendMetric.value === metric) return + userTrendMetric.value = metric + userTrend.value = [] + loadUsersTrend() +} + const loadUsersTrend = async () => { const currentSeq = ++usersTrendLoadSeq userTrendLoading.value = true @@ -687,7 +705,8 @@ const loadUsersTrend = async () => { start_date: startDate.value, end_date: endDate.value, granularity: granularity.value, - limit: 12 + limit: 12, + metric: userTrendMetric.value }) if (currentSeq !== usersTrendLoadSeq) return userTrend.value = response.trend || [] diff --git a/frontend/src/views/admin/__tests__/DashboardView.spec.ts b/frontend/src/views/admin/__tests__/DashboardView.spec.ts index ded7db034f54..898267e2e891 100644 --- a/frontend/src/views/admin/__tests__/DashboardView.spec.ts +++ b/frontend/src/views/admin/__tests__/DashboardView.spec.ts @@ -11,6 +11,10 @@ const { getSnapshotV2, getUserUsageTrend, getUserSpendingRanking } = vi.hoisted( getUserSpendingRanking: vi.fn() })) +vi.mock('vue-chartjs', () => ({ + Line: { name: 'Line', props: ['data', 'options'], template: '
' } +})) + vi.mock('@/api/admin', () => ({ adminAPI: { dashboard: { @@ -115,6 +119,30 @@ describe('admin DashboardView', () => { }) }) + it('switches metrics and ignores a stale tokens response', async () => { + let resolveTokens!: (value: any) => void + getUserUsageTrend.mockImplementationOnce(() => new Promise(resolve => { resolveTokens = resolve })) + getUserUsageTrend.mockResolvedValueOnce({ trend: [{ date: '2026-01-01', user_id: 2, username: 'spender', email: '', requests: 1, tokens: 10, cost: 5, actual_cost: 5 }] }) + const wrapper = mount(DashboardView, { + global: { stubs: { + AppLayout: { template: '
' }, LoadingSpinner: true, + Icon: true, DateRangePicker: true, Select: true, ModelDistributionChart: true, + TokenUsageTrend: true + } } + }) + await flushPromises() + expect(getUserUsageTrend).toHaveBeenCalledWith(expect.objectContaining({ metric: 'tokens', limit: 12 })) + await wrapper.findAll('button').find(button => button.text() === 'admin.dashboard.actualSpending')!.trigger('click') + expect(getUserUsageTrend).toHaveBeenCalledWith(expect.objectContaining({ metric: 'actual_cost', limit: 12 })) + await flushPromises() + const chart = wrapper.findComponent({ name: 'Line' }) + expect(chart.props('data').datasets[0].data).toEqual([5]) + expect(chart.props('options').scales.y.ticks.callback(5)).toBe('$5.00') + resolveTokens({ trend: [{ date: '2026-01-01', user_id: 1, username: 'tokens', email: '', requests: 1, tokens: 100, cost: 1, actual_cost: 1 }] }) + await flushPromises() + expect(chart.props('data').datasets[0].data).toEqual([5]) + }) + it('uses last 24 hours as default dashboard range', async () => { mount(DashboardView, { global: { From 2840cdeced31574e6007e2244d832689ee45d73d Mon Sep 17 00:00:00 2001 From: PMExtra Date: Wed, 23 Sep 2026 17:50:02 +0800 Subject: [PATCH 12/42] feat(frontend): hide unsupported clients for Claude Code-only groups --- frontend/src/components/keys/UseKeyModal.vue | 7 +++- .../keys/__tests__/UseKeyModal.spec.ts | 38 +++++++++++++++++++ frontend/src/views/user/KeysView.vue | 1 + 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 242817d1cced..5966aa582ba3 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -275,6 +275,7 @@ interface Props { apiKey: string baseUrl: string platform: GroupPlatform | null + claudeCodeOnly?: boolean allowMessagesDispatch?: boolean } @@ -336,6 +337,7 @@ const codexManifestContext = computed(() => { // Reset tabs when platform changes const defaultClientTab = computed(() => { + if (props.claudeCodeOnly) return 'claude' switch (props.platform) { case 'openai': return 'codex' @@ -350,7 +352,7 @@ const defaultClientTab = computed(() => { } }) -watch(() => props.platform, () => { +watch(() => [props.platform, props.claudeCodeOnly], () => { activeTab.value = 'unix' activeClientTab.value = defaultClientTab.value codexAuthMode.value = 'legacy' @@ -440,6 +442,9 @@ const SparkleIcon = { const clientTabs = computed((): TabConfig[] => { if (!props.platform) return [] + if (props.claudeCodeOnly) { + return [{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon }] + } switch (props.platform) { case 'openai': { const tabs: TabConfig[] = [ diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index 7d2a939ed1ba..e02cc53152a0 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -40,6 +40,44 @@ describe('UseKeyModal', () => { saveAsMock.mockClear() }) + it('shows only Claude Code for Claude Code-only groups', async () => { + const wrapper = mount(UseKeyModal, { + props: { + show: true, + apiKey: 'sk-anthropic-test', + baseUrl: 'https://example.com/v1', + platform: 'anthropic' + }, + global: { + stubs: { + BaseDialog: { template: '
' }, + Icon: { template: '' } + } + } + }) + + const clientTabs = () => wrapper.find('nav[aria-label="Client"]').text() + expect(clientTabs()).toContain('keys.useKeyModal.cliTabs.codexCli') + expect(clientTabs()).toContain('keys.useKeyModal.cliTabs.opencode') + + const codexTab = wrapper.find('nav[aria-label="Client"]').findAll('button').find( + (button) => button.text().includes('keys.useKeyModal.cliTabs.codexCli') + ) + await codexTab!.trigger('click') + await wrapper.setProps({ claudeCodeOnly: true }) + + expect(clientTabs()).toContain('keys.useKeyModal.cliTabs.claudeCode') + expect(clientTabs()).not.toContain('keys.useKeyModal.cliTabs.codexCli') + expect(clientTabs()).not.toContain('keys.useKeyModal.cliTabs.opencode') + expect(wrapper.find('pre code').text()).toContain('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC') + + await wrapper.setProps({ platform: 'openai' }) + expect(clientTabs()).toContain('keys.useKeyModal.cliTabs.claudeCode') + expect(clientTabs()).not.toContain('keys.useKeyModal.cliTabs.codexCli') + expect(clientTabs()).not.toContain('keys.useKeyModal.cliTabs.opencode') + expect(wrapper.find('pre code').text()).toContain('ANTHROPIC_BASE_URL') + }) + it('omits the attribution override from every standard Claude Code setup form', async () => { const wrapper = mount(UseKeyModal, { props: { diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue index 3011b52a1147..66ac097cb1b8 100644 --- a/frontend/src/views/user/KeysView.vue +++ b/frontend/src/views/user/KeysView.vue @@ -1075,6 +1075,7 @@ :api-key="selectedKey?.key || ''" :base-url="publicSettings?.api_base_url || ''" :platform="selectedKey?.group?.platform || null" + :claude-code-only="selectedKey?.group?.claude_code_only || false" :allow-messages-dispatch="selectedKey?.group?.allow_messages_dispatch || false" @close="closeUseKeyModal" /> From 6a69dd051a66a37ab2f0a9427f4b2bffcb878012 Mon Sep 17 00:00:00 2001 From: PMExtra Date: Thu, 24 Sep 2026 21:27:57 +0800 Subject: [PATCH 13/42] feat: add risk control user allowlist --- .../internal/handler/admin/setting_handler.go | 1 + .../handler/admin/setting_handler_audit.go | 3 + .../handler/admin/setting_handler_update.go | 19 ++- backend/internal/handler/dto/settings.go | 5 +- .../handler/openai_cyber_allowlist.go | 18 +++ .../handler/openai_cyber_allowlist_test.go | 79 +++++++++ .../handler/openai_gateway_handler.go | 14 +- .../openai_ws_v2_passthrough_cyber_test.go | 10 +- .../repository/content_moderation_repo.go | 4 + .../content_moderation_repo_test.go | 15 ++ backend/internal/server/api_contract_test.go | 2 + .../internal/service/content_moderation.go | 77 ++++++--- .../service/cyber_policy_allowlist.go | 50 ++++++ .../service/cyber_policy_allowlist_test.go | 62 +++++++ backend/internal/service/domain_constants.go | 1 + .../service/risk_control_allowlist_test.go | 153 ++++++++++++++++++ .../service/setting_cyber_allowlist_test.go | 42 +++++ .../service/setting_gateway_runtime.go | 42 +++-- backend/internal/service/setting_parse.go | 2 + backend/internal/service/setting_service.go | 1 + backend/internal/service/setting_update.go | 9 ++ backend/internal/service/settings_view.go | 1 + frontend/src/api/admin/settings.ts | 2 + .../src/i18n/locales/en/admin/settings.ts | 2 + .../src/i18n/locales/zh/admin/settings.ts | 2 + frontend/src/views/admin/SettingsView.vue | 27 ++++ 26 files changed, 598 insertions(+), 45 deletions(-) create mode 100644 backend/internal/handler/openai_cyber_allowlist.go create mode 100644 backend/internal/handler/openai_cyber_allowlist_test.go create mode 100644 backend/internal/service/cyber_policy_allowlist.go create mode 100644 backend/internal/service/cyber_policy_allowlist_test.go create mode 100644 backend/internal/service/risk_control_allowlist_test.go create mode 100644 backend/internal/service/setting_cyber_allowlist_test.go diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 036fbbfca27c..7bff15c48a8b 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -265,6 +265,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { DefaultBalance: settings.DefaultBalance, RiskControlEnabled: settings.RiskControlEnabled, CyberSessionBlockEnabled: settings.CyberSessionBlockEnabled, + CyberPolicyUserAllowlist: settings.CyberPolicyUserAllowlist, CyberSessionBlockTTLSeconds: settings.CyberSessionBlockTTLSeconds, AffiliateRebateRate: settings.AffiliateRebateRate, AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index b67eb88c49ff..81e48f6772b6 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -603,6 +603,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.RiskControlEnabled != after.RiskControlEnabled { changed = append(changed, "risk_control_enabled") } + if before.CyberPolicyUserAllowlist != after.CyberPolicyUserAllowlist { + changed = append(changed, "cyber_policy_user_allowlist") + } if before.CyberSessionBlockEnabled != after.CyberSessionBlockEnabled { changed = append(changed, "cyber_session_block_enabled") } diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 8ca112e7f704..618f0bde5ca8 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -364,8 +364,9 @@ type UpdateSettingsRequest struct { RiskControlEnabled *bool `json:"risk_control_enabled"` // cyber 会话屏蔽开关 + TTL - CyberSessionBlockEnabled *bool `json:"cyber_session_block_enabled"` - CyberSessionBlockTTLSeconds *int `json:"cyber_session_block_ttl_seconds"` + CyberSessionBlockEnabled *bool `json:"cyber_session_block_enabled"` + CyberPolicyUserAllowlist *string `json:"cyber_policy_user_allowlist"` + CyberSessionBlockTTLSeconds *int `json:"cyber_session_block_ttl_seconds"` // OpenAI fast/flex policy (optional, only updated when provided) OpenAIFastPolicySettings *dto.OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"` @@ -1503,6 +1504,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + if req.CyberPolicyUserAllowlist != nil { + if _, err := service.ParseCyberPolicyUserAllowlist(*req.CyberPolicyUserAllowlist); err != nil { + response.BadRequest(c, err.Error()) + return + } + } + // cyber 会话屏蔽 TTL 校验:提供时必须 > 0 if req.CyberSessionBlockTTLSeconds != nil && *req.CyberSessionBlockTTLSeconds <= 0 { response.BadRequest(c, "cyber_session_block_ttl_seconds must be > 0") @@ -2008,6 +2016,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.RiskControlEnabled }(), + CyberPolicyUserAllowlist: func() string { + if req.CyberPolicyUserAllowlist != nil { + return *req.CyberPolicyUserAllowlist + } + return previousSettings.CyberPolicyUserAllowlist + }(), CyberSessionBlockEnabled: func() bool { if req.CyberSessionBlockEnabled != nil { return *req.CyberSessionBlockEnabled @@ -2429,6 +2443,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { RiskControlEnabled: updatedSettings.RiskControlEnabled, CyberSessionBlockEnabled: updatedSettings.CyberSessionBlockEnabled, + CyberPolicyUserAllowlist: updatedSettings.CyberPolicyUserAllowlist, CyberSessionBlockTTLSeconds: updatedSettings.CyberSessionBlockTTLSeconds, AccountSchedulingThresholds: updatedSettings.AccountSchedulingThresholds, AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 72f2a56cbc43..4f16f11d453d 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -335,8 +335,9 @@ type SystemSettings struct { RiskControlEnabled bool `json:"risk_control_enabled"` // cyber 会话屏蔽开关 + TTL - CyberSessionBlockEnabled bool `json:"cyber_session_block_enabled"` - CyberSessionBlockTTLSeconds int `json:"cyber_session_block_ttl_seconds"` + CyberSessionBlockEnabled bool `json:"cyber_session_block_enabled"` + CyberPolicyUserAllowlist string `json:"cyber_policy_user_allowlist"` + CyberSessionBlockTTLSeconds int `json:"cyber_session_block_ttl_seconds"` // Affiliate (邀请返利) feature switch AffiliateEnabled bool `json:"affiliate_enabled"` diff --git a/backend/internal/handler/openai_cyber_allowlist.go b/backend/internal/handler/openai_cyber_allowlist.go new file mode 100644 index 000000000000..7112bff0ade8 --- /dev/null +++ b/backend/internal/handler/openai_cyber_allowlist.go @@ -0,0 +1,18 @@ +package handler + +import ( + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +func (h *OpenAIGatewayHandler) cyberPolicyLogOnly(c *gin.Context, apiKey *service.APIKey) bool { + return h != nil && c != nil && c.Request != nil && h.gatewayService.CyberPolicyLogOnly(c.Request.Context(), apiKey) +} + +// Both HTTP and WebSocket admission skip existing blocks for trusted users. +func (h *OpenAIGatewayHandler) findBlockedCyberSessionForAPIKey(c *gin.Context, apiKey *service.APIKey, body []byte) string { + if apiKey == nil || h.cyberPolicyLogOnly(c, apiKey) { + return "" + } + return findBlockedCyberSessionKey(c.Request.Context(), h.gatewayService, apiKey.ID, c, body) +} diff --git a/backend/internal/handler/openai_cyber_allowlist_test.go b/backend/internal/handler/openai_cyber_allowlist_test.go new file mode 100644 index 000000000000..22bb67458686 --- /dev/null +++ b/backend/internal/handler/openai_cyber_allowlist_test.go @@ -0,0 +1,79 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + coderws "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestCyberAllowlistedUserBypassesExistingBlocksAndContinuesWebSocket(t *testing.T) { + gin.SetMode(gin.TestMode) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.CloseNow() }() + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + for _, response := range []string{ + `{"type":"response.failed","response":{"id":"resp_cyber","model":"gpt-5.1","error":{"code":"cyber_policy","message":"blocked by upstream"}}}`, + `{"type":"response.completed","response":{"id":"resp_ok","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`, + } { + if _, _, err := conn.Read(ctx); err != nil { + return + } + if err := conn.Write(ctx, coderws.MessageText, []byte(response)); err != nil { + return + } + } + // Let the downstream close so the completed frame can drain normally. + _, _, _ = conn.Read(ctx) + })) + defer upstream.Close() + harness := newOpenAIWSPassthroughHandlerHarness(t, upstream.URL, map[string]string{ + service.SettingKeyCyberPolicyUserAllowlist: "1751", + }) + payload := []byte(`{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"trusted-session","input":"test"}`) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader(string(payload))) + explicitKey := service.CyberSessionExplicitBlockKey(harness.apiKey.ID, c, payload) + store, ok := harness.gatewayCache.(service.CyberSessionBlockStore) + require.True(t, ok) + require.NoError(t, store.SetCyberSessionBlocked(context.Background(), "", []string{explicitKey}, time.Minute)) + for _, format := range []cyberSessionBlockFormat{cyberBlockFormatResponses, cyberBlockFormatChat, cyberBlockFormatAnthropic} { + require.False(t, harness.handler.rejectIfCyberSessionBlocked(c, harness.apiKey, payload, "gpt-5.1", format)) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, harness.clientConn.Write(ctx, coderws.MessageText, payload)) + _, event, err := harness.clientConn.Read(ctx) + require.NoError(t, err) + require.Equal(t, "cyber_policy", gjson.GetBytes(event, "response.error.code").String()) + require.Eventually(t, func() bool { + logs := harness.moderationRepo.logSnapshot() + return len(logs) == 1 && logs[0].Mode == service.ContentModerationModeCyberLogOnly + }, 3*time.Second, 10*time.Millisecond) + matched, err := store.FindCyberSessionBlocked(ctx, service.CyberSessionTranscriptBlockKeys(harness.apiKey.ID, payload)) + require.NoError(t, err) + require.Empty(t, matched, "the cyber response must not write new transcript blocks") + require.NoError(t, harness.clientConn.Write(ctx, coderws.MessageText, payload)) + _, event, err = harness.clientConn.Read(ctx) + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + _ = harness.clientConn.CloseNow() + select { + case <-harness.handlerDone: + case <-ctx.Done(): + t.Fatal("handler did not exit") + } +} diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index bf4563f2f207..305f369a5321 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -2440,7 +2440,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // The first response.create frame is available here, so explicit IDs are // checked directly and body-derived sessions use the coarse scope gate. - if cyberBlockKey := findBlockedCyberSessionKey(c.Request.Context(), h.gatewayService, apiKey.ID, c, firstMessage); cyberBlockKey != "" { + if cyberBlockKey := h.findBlockedCyberSessionForAPIKey(c, apiKey, firstMessage); cyberBlockKey != "" { writeCyberSessionBlockedWSError(c.Request.Context(), wsConn) closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "session blocked by cyber-security policy") h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, reqModel, cyberBlockKey) @@ -2817,7 +2817,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // 连接级 cyber session gate 也在 BeforeRequest 先执行,使 native 与 // passthrough ingress 都能在 BeforeTurn 及上游写入前无副作用地拒绝。 // BeforeTurn 中保留同一检查作为防御式兜底。 - if cyberBlockedThisConn { + if cyberBlockedThisConn && !h.cyberPolicyLogOnly(c, apiKey) { return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil) } if turn == 1 { @@ -2869,7 +2869,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { }, BeforeTurn: func(turn int) error { // turn==1 的会话屏蔽已由握手层检查覆盖;连接内 flag 只拦截后续 turn。 - if cyberBlockedThisConn { + if cyberBlockedThisConn && !h.cyberPolicyLogOnly(c, apiKey) { return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil) } // 长连接跨峰谷/倍率刷新防护:每个 turn 按当前时刻重装门并复核 @@ -2949,7 +2949,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { cyberBlockedThisConn, cyberBlockPendingAfterFailover = advanceOpenAIWSCyberBlockState( cyberBlockedThisConn, cyberBlockPendingAfterFailover, - cyberMarked, + cyberMarked && !h.cyberPolicyLogOnly(c, apiKey), turnErr, ) if turnErr != nil { @@ -4045,7 +4045,7 @@ func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKe if enabled, _ := h.gatewayService.CyberSessionBlockRuntime(c.Request.Context()); !enabled { return false } - key := findBlockedCyberSessionKey(c.Request.Context(), h.gatewayService, apiKey.ID, c, body) + key := h.findBlockedCyberSessionForAPIKey(c, apiKey, body) if key == "" { return false } @@ -4242,7 +4242,8 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey ClientIP: clientIPStr, CreatedAt: time.Now(), } - if gwSvc != nil && apiKey != nil { + cyberLogOnly := h.cyberPolicyLogOnly(c, apiKey) + if gwSvc != nil && apiKey != nil && !cyberLogOnly { plan := buildCyberSessionBlockWritePlan(apiKey.ID, c, cyberBlockBody) if len(plan.keys) > 0 { blockCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) @@ -4255,6 +4256,7 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey defer cancel() if cmSvc != nil { cmSvc.RecordCyberPolicyEvent(ctx, service.CyberPolicyRecordInput{ + LogOnly: cyberLogOnly, RequestID: requestID, UserID: userID, UserEmail: userEmail, diff --git a/backend/internal/handler/openai_ws_v2_passthrough_cyber_test.go b/backend/internal/handler/openai_ws_v2_passthrough_cyber_test.go index 49e17c3e758b..61ba54488a36 100644 --- a/backend/internal/handler/openai_ws_v2_passthrough_cyber_test.go +++ b/backend/internal/handler/openai_ws_v2_passthrough_cyber_test.go @@ -19,6 +19,7 @@ import ( ) type openAIWSPassthroughHandlerHarness struct { + handler *OpenAIGatewayHandler clientConn *coderws.Conn handlerDone <-chan struct{} moderationRepo *contentModerationHandlerTestRepo @@ -26,7 +27,7 @@ type openAIWSPassthroughHandlerHarness struct { apiKey *service.APIKey } -func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *openAIWSPassthroughHandlerHarness { +func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string, settings ...map[string]string) *openAIWSPassthroughHandlerHarness { t.Helper() gatewayCache := testutil.NewRedisGatewayCache(t) @@ -35,6 +36,11 @@ func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *ope service.SettingKeyCyberSessionBlockEnabled: "true", service.SettingKeyCyberSessionBlockTTLSeconds: "60", }} + for _, overrides := range settings { + for key, value := range overrides { + settingRepo.values[key] = value + } + } moderationRepo := &contentModerationHandlerTestRepo{} moderationSvc := service.NewContentModerationService(settingRepo, moderationRepo, nil, nil, nil, nil, nil, nil) settingSvc := service.NewSettingService(settingRepo, nil) @@ -90,6 +96,7 @@ func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *ope apiKey := &service.APIKey{ ID: 1851, + UserID: 1751, Name: "ws-cyber-key", Key: "sk-handler-cyber-test", GroupID: &groupID, @@ -116,6 +123,7 @@ func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *ope t.Cleanup(func() { _ = clientConn.CloseNow() }) return &openAIWSPassthroughHandlerHarness{ + handler: h, clientConn: clientConn, handlerDone: handlerDone, moderationRepo: moderationRepo, diff --git a/backend/internal/repository/content_moderation_repo.go b/backend/internal/repository/content_moderation_repo.go index 32b8e9150f74..20142bfd5e67 100644 --- a/backend/internal/repository/content_moderation_repo.go +++ b/backend/internal/repository/content_moderation_repo.go @@ -197,6 +197,8 @@ func (r *contentModerationRepository) CountFlaggedByUserSince(ctx context.Contex return 0, nil } // SQL 中的 'cyber_policy' 字面量须与 service.ContentModerationActionCyberPolicy 保持一致。 + // 'cyber_log_only' matches service.ContentModerationModeCyberLogOnly; these + // events remain evidence but never become penalties after allowlist removal. var count int err := r.db.QueryRowContext(ctx, ` WITH last_auto_ban AS ( @@ -209,6 +211,8 @@ FROM content_moderation_logs WHERE user_id = $1 AND flagged = TRUE AND action <> 'hash_block' + AND mode <> 'cyber_log_only' + AND mode <> 'risk_control_log_only' AND ($3::bool IS FALSE OR action <> 'cyber_policy') AND created_at >= $2 AND created_at > COALESCE((SELECT at FROM last_auto_ban), '-infinity'::timestamptz) diff --git a/backend/internal/repository/content_moderation_repo_test.go b/backend/internal/repository/content_moderation_repo_test.go index 2b32d09f97b1..7be148c14975 100644 --- a/backend/internal/repository/content_moderation_repo_test.go +++ b/backend/internal/repository/content_moderation_repo_test.go @@ -102,3 +102,18 @@ func TestContentModerationRepositoryCountFlaggedByUserSince_ExcludesCyberPolicyW require.Equal(t, 3, count) require.NoError(t, mock.ExpectationsWereMet()) } + +func TestContentModerationRepositoryCountFlaggedByUserSince_ExcludesCyberLogOnly(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + repo := NewContentModerationRepository(db) + since := time.Now().Add(-time.Hour) + mock.ExpectQuery(regexp.QuoteMeta("AND mode <> 'cyber_log_only'\n AND mode <> 'risk_control_log_only'")). + WithArgs(int64(12), since, false). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + count, err := repo.CountFlaggedByUserSince(context.Background(), 12, since, false) + require.NoError(t, err) + require.Zero(t, count) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 219c83b149bf..f4d4c4a0199d 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1004,6 +1004,7 @@ func TestAPIContracts(t *testing.T) { "model_plaza_description": "", "plugin_management_enabled": false, "risk_control_enabled": false, + "cyber_policy_user_allowlist": "", "cyber_session_block_enabled": false, "cyber_session_block_ttl_seconds": 3600, "affiliate_enabled": false, @@ -1322,6 +1323,7 @@ func TestAPIContracts(t *testing.T) { "model_plaza_description": "", "plugin_management_enabled": false, "risk_control_enabled": false, + "cyber_policy_user_allowlist": "", "cyber_session_block_enabled": false, "cyber_session_block_ttl_seconds": 3600, "affiliate_enabled": false, diff --git a/backend/internal/service/content_moderation.go b/backend/internal/service/content_moderation.go index cb000e2a0a25..3c6df48ed216 100644 --- a/backend/internal/service/content_moderation.go +++ b/backend/internal/service/content_moderation.go @@ -316,18 +316,19 @@ type ContentModerationModelFilter struct { } type ContentModerationCheckInput struct { - RequestID string - UserID int64 - UserEmail string - APIKeyID int64 - APIKeyName string - GroupID *int64 - GroupName string - Endpoint string - Provider string - Model string - Protocol string - Body []byte + riskControlLogOnly bool // Captured at admission and retained by asynchronous tasks. + RequestID string + UserID int64 + UserEmail string + APIKeyID int64 + APIKeyName string + GroupID *int64 + GroupName string + Endpoint string + Provider string + Model string + Protocol string + Body []byte } type ContentModerationInput struct { @@ -545,6 +546,7 @@ type ContentModerationService struct { } type contentModerationRuntimeSnapshot struct { + allowlistedUsers map[int64]struct{} riskControlEnabled bool config *ContentModerationConfig keywordMatcher *contentModerationKeywordMatcher @@ -807,7 +809,7 @@ func (s *ContentModerationService) TestAPIKeys(ctx context.Context, input TestCo return &TestContentModerationAPIKeysResult{Items: items, AuditResult: auditResult, ImageCount: imageCount}, nil } -func (s *ContentModerationService) Check(ctx context.Context, input ContentModerationCheckInput) (*ContentModerationDecision, error) { +func (s *ContentModerationService) Check(ctx context.Context, input ContentModerationCheckInput) (decision *ContentModerationDecision, err error) { allow := &ContentModerationDecision{Allowed: true, Action: ContentModerationActionAllow} if s == nil || s.settingRepo == nil || s.repo == nil { slog.Info("content_moderation.skip_unavailable", @@ -838,6 +840,15 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer "protocol", input.Protocol) return allow, nil } + _, input.riskControlLogOnly = runtimeSnapshot.allowlistedUsers[input.UserID] + // Keep audit evidence while ensuring all local rejection paths allow trusted users. + defer func() { + if input.riskControlLogOnly && decision != nil { + decision.Allowed, decision.Blocked = true, false + decision.Action = ContentModerationActionAllow + decision.Message, decision.StatusCode = "", 0 + } + }() cfg := runtimeSnapshot.config inGroupScope := cfg.includesGroup(input.GroupID) inModelScope := cfg.includesModel(input.Model) @@ -909,7 +920,11 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer if cfg.KeywordBlockingMode != ContentModerationKeywordModeAPIOnly && len(cfg.BlockedKeywords) > 0 { keywordText := extractContentModerationKeywordText(input.Protocol, input.Body) if keyword, hit := runtimeSnapshot.matchBlockedKeyword(keywordText); hit { - s.recordPreBlockSyncMetric(0, ContentModerationActionKeywordBlock) + metricAction := ContentModerationActionKeywordBlock + if input.riskControlLogOnly { + metricAction = ContentModerationActionAllow + } + s.recordPreBlockSyncMetric(0, metricAction) slog.Info("content_moderation.keyword_block", "user_id", input.UserID, "api_key_id", input.APIKeyID, @@ -974,7 +989,11 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer } if matched { if cfg.Mode == ContentModerationModePreBlock { - s.recordPreBlockSyncMetric(0, ContentModerationActionHashBlock) + metricAction := ContentModerationActionHashBlock + if input.riskControlLogOnly { + metricAction = ContentModerationActionAllow + } + s.recordPreBlockSyncMetric(0, metricAction) } slog.Info("content_moderation.hash_block", "user_id", input.UserID, @@ -1081,7 +1100,7 @@ func (s *ContentModerationService) checkSync(ctx context.Context, input ContentM flagged, highestCategory, highestScore := evaluateModerationScores(result.CategoryScores, cfg.Thresholds) action := ContentModerationActionAllow blocked := false - if allowBlock && flagged && cfg.Mode == ContentModerationModePreBlock { + if allowBlock && !input.riskControlLogOnly && flagged && cfg.Mode == ContentModerationModePreBlock { action = ContentModerationActionBlock blocked = true } @@ -1572,14 +1591,20 @@ func (s *ContentModerationService) refreshRuntimeSnapshot(ctx context.Context) ( values, err := s.settingRepo.GetMultiple(ctx, []string{ SettingKeyRiskControlEnabled, SettingKeyContentModerationConfig, + SettingKeyCyberPolicyUserAllowlist, }) if err != nil { return nil, fmt.Errorf("get content moderation runtime settings: %w", err) } + allowlistedUsers, err := ParseCyberPolicyUserAllowlist(values[SettingKeyCyberPolicyUserAllowlist]) + if err != nil { + return nil, err + } rawConfig := values[SettingKeyContentModerationConfig] configDigest := sha256.Sum256([]byte(rawConfig)) if current := s.runtimeSnapshot.Load(); current != nil && current.configDigest == configDigest { snapshot := &contentModerationRuntimeSnapshot{ + allowlistedUsers: allowlistedUsers, riskControlEnabled: values[SettingKeyRiskControlEnabled] == "true", config: current.config, keywordMatcher: current.keywordMatcher, @@ -1596,6 +1621,7 @@ func (s *ContentModerationService) refreshRuntimeSnapshot(ctx context.Context) ( } cfg = cfg.effectiveEngine(cfg.Engine) snapshot := &contentModerationRuntimeSnapshot{ + allowlistedUsers: allowlistedUsers, riskControlEnabled: values[SettingKeyRiskControlEnabled] == "true", config: cfg, keywordMatcher: newContentModerationKeywordMatcher(cfg.BlockedKeywords), @@ -1628,6 +1654,7 @@ func (s *ContentModerationService) replaceRuntimeConfig(cfg *ContentModerationCo return } s.runtimeSnapshot.Store(&contentModerationRuntimeSnapshot{ + allowlistedUsers: current.allowlistedUsers, riskControlEnabled: current.riskControlEnabled, config: config, keywordMatcher: keywordMatcher, @@ -1874,6 +1901,11 @@ func (s *ContentModerationService) buildLog(input ContentModerationCheckInput, c if input.APIKeyID > 0 { apiKeyID = &input.APIKeyID } + mode := cfg.Mode + if input.riskControlLogOnly { + mode = ContentModerationModeRiskControlLogOnly + action = ContentModerationActionAllow + } return &ContentModerationLog{ RequestID: input.RequestID, UserID: userID, @@ -1885,7 +1917,7 @@ func (s *ContentModerationService) buildLog(input ContentModerationCheckInput, c Endpoint: input.Endpoint, Provider: input.Provider, Model: input.Model, - Mode: cfg.Mode, + Mode: mode, Action: action, Flagged: flagged, HighestCategory: highestCategory, @@ -1903,6 +1935,9 @@ func (s *ContentModerationService) persistContentModerationLog(ctx context.Conte if s == nil || log == nil { return } + if log.Mode == ContentModerationModeRiskControlLogOnly { + recordHash, applySideEffects = false, false + } if recordHash && s.hashCache != nil { if err := s.hashCache.RecordFlaggedInputHash(ctx, hashText); err != nil { slog.Warn("content_moderation.record_hash_failed", "user_id", contentModerationEmailUserID(log), "endpoint", log.Endpoint, "error", err) @@ -2990,6 +3025,7 @@ func maskSecretTail(secret string) string { // CyberPolicyRecordInput 是一次 cyber_policy 硬阻断的风控记录入参。 type CyberPolicyRecordInput struct { + LogOnly bool // Trusted platform user: retain evidence without penalties or notifications. RequestID string UserID int64 UserEmail string @@ -3064,7 +3100,10 @@ func (s *ContentModerationService) RecordCyberPolicyEvent(ctx context.Context, i // 开关开时 cyber_policy 不参与封号计数:当次不判定(此处跳过), // 历史行由 CountFlaggedByUserSince 的 excludeCyberPolicy 排除。 autoBanned := false - if !cfg.CyberPolicyExcludeFromBanCount { + if in.LogOnly { + log.Mode = ContentModerationModeCyberLogOnly + } + if !in.LogOnly && !cfg.CyberPolicyExcludeFromBanCount { autoBanned = s.applyFlaggedAccountSideEffects(ctx, cfg, log) } log.EmailSent = false @@ -3074,7 +3113,7 @@ func (s *ContentModerationService) RecordCyberPolicyEvent(ctx context.Context, i slog.Warn("content_moderation.cyber_create_log_failed", "user_id", in.UserID, "error", err) } emailSent := false - if s.emailService != nil && strings.TrimSpace(log.UserEmail) != "" { + if !in.LogOnly && s.emailService != nil && strings.TrimSpace(log.UserEmail) != "" { if err := s.sendCyberPolicyEmail(ctx, log); err != nil { slog.Warn("content_moderation.cyber_email_failed", "user_id", in.UserID, "error", err) } else { diff --git a/backend/internal/service/cyber_policy_allowlist.go b/backend/internal/service/cyber_policy_allowlist.go new file mode 100644 index 000000000000..ed3fb59ef008 --- /dev/null +++ b/backend/internal/service/cyber_policy_allowlist.go @@ -0,0 +1,50 @@ +package service + +import ( + "context" + "fmt" + "strconv" + "strings" + "unicode" +) + +// ContentModerationModeCyberLogOnly preserves upstream evidence while excluding +// these events from subsequent automatic-ban counts, even after whitelist removal. +const ContentModerationModeCyberLogOnly = "cyber_log_only" +const ContentModerationModeRiskControlLogOnly = "risk_control_log_only" + +// The historical cyber_policy_user_allowlist storage/API key also governs ordinary moderation. + +// ParseCyberPolicyUserAllowlist accepts positive platform user IDs separated by +// commas or whitespace. Reject the whole list on invalid input; never accept a +// partially parsed privilege configuration. +func ParseCyberPolicyUserAllowlist(raw string) (map[int64]struct{}, error) { + if len(raw) > 16384 { + return nil, fmt.Errorf("cyber_policy_user_allowlist must not exceed 16384 bytes") + } + ids := make(map[int64]struct{}) + for _, field := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || unicode.IsSpace(r) }) { + id, err := strconv.ParseInt(field, 10, 64) + if err != nil || id <= 0 { + return nil, fmt.Errorf("cyber_policy_user_allowlist must contain positive user IDs separated by commas or whitespace") + } + ids[id] = struct{}{} + } + return ids, nil +} + +func (s *SettingService) IsCyberPolicyUserAllowlisted(ctx context.Context, userID int64) bool { + if s == nil || s.settingRepo == nil || userID <= 0 { + return false + } + s.GetCyberSessionBlockRuntime(ctx) + if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil { + _, allowed := cached.allowlistedUsers[userID] + return allowed + } + return false +} + +func (s *OpenAIGatewayService) CyberPolicyLogOnly(ctx context.Context, apiKey *APIKey) bool { + return s != nil && apiKey != nil && s.settingService.IsCyberPolicyUserAllowlisted(ctx, apiKey.UserID) +} diff --git a/backend/internal/service/cyber_policy_allowlist_test.go b/backend/internal/service/cyber_policy_allowlist_test.go new file mode 100644 index 000000000000..32c945182540 --- /dev/null +++ b/backend/internal/service/cyber_policy_allowlist_test.go @@ -0,0 +1,62 @@ +package service + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseCyberPolicyUserAllowlist(t *testing.T) { + ids, err := ParseCyberPolicyUserAllowlist("12, 34\n12\t56") + require.NoError(t, err) + require.Len(t, ids, 3) + for _, raw := range []string{"0", "-1", "12,invalid", "9223372036854775808", strings.Repeat("1", 16385)} { + ids, err := ParseCyberPolicyUserAllowlist(raw) + require.Error(t, err) + require.Nil(t, ids) + } + ids, err = ParseCyberPolicyUserAllowlist("") + require.NoError(t, err) + require.Empty(t, ids) +} + +func TestCyberPolicyAllowlistCoversAllUserKeysAndRefreshes(t *testing.T) { + repo := &fakeSettingRepo{vals: map[string]string{SettingKeyCyberPolicyUserAllowlist: "12"}} + settings := &SettingService{settingRepo: repo} + svc := &OpenAIGatewayService{settingService: settings} + ctx := context.Background() + // The allowlist also applies when session blocking is disabled. + require.True(t, svc.CyberPolicyLogOnly(ctx, &APIKey{ID: 1, UserID: 12})) + require.True(t, svc.CyberPolicyLogOnly(ctx, &APIKey{ID: 2, UserID: 12})) + require.False(t, svc.CyberPolicyLogOnly(ctx, &APIKey{ID: 3, UserID: 34})) + require.False(t, svc.CyberPolicyLogOnly(ctx, nil)) + repo.vals[SettingKeyCyberPolicyUserAllowlist] = "34" + settings.cyberSessionBlockRuntimeCache.Store(&cachedCyberSessionBlockRuntime{}) + require.False(t, svc.CyberPolicyLogOnly(ctx, &APIKey{ID: 1, UserID: 12})) + require.True(t, svc.CyberPolicyLogOnly(ctx, &APIKey{ID: 3, UserID: 34})) +} + +func TestRecordCyberPolicyEventLogOnlyPreservesEvidenceWithoutSideEffects(t *testing.T) { + repo := &banCountArgsTestRepo{} + svc := NewContentModerationService( + &contentModerationTestSettingRepo{values: map[string]string{SettingKeyRiskControlEnabled: "true"}}, + repo, nil, nil, nil, nil, nil, &EmailService{}, + ) + // An unconfigured email service must never be invoked in log-only mode. + svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{ + LogOnly: true, UserID: 12, UserEmail: "test@example.com", Model: "gpt-5", + UpstreamMessage: "blocked", UpstreamBody: `{"error":{"code":"cyber_policy"}}`, + }) + logs := repo.snapshotLogs() + require.Len(t, logs, 1) + require.Equal(t, ContentModerationModeCyberLogOnly, logs[0].Mode) + require.Equal(t, ContentModerationActionCyberPolicy, logs[0].Action) + require.True(t, logs[0].Flagged) + require.Contains(t, logs[0].Error, "cyber_policy") + require.False(t, logs[0].AutoBanned) + require.False(t, logs[0].EmailSent) + require.Zero(t, logs[0].ViolationCount) + require.Empty(t, repo.snapshotCountCalls()) +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 9118d28316ad..82477d380803 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -240,6 +240,7 @@ const ( SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路 SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON) SettingKeyCyberSessionBlockEnabled = "cyber_session_block_enabled" // cyber 命中后会话级自动屏蔽总开关(默认关) + SettingKeyCyberPolicyUserAllowlist = "cyber_policy_user_allowlist" // Platform user IDs with log-only cyber handling SettingKeyCyberSessionBlockTTLSeconds = "cyber_session_block_ttl_seconds" // 会话屏蔽 TTL 秒数(默认 3600) SettingKeyLoginAgreementEnabled = "login_agreement_enabled" // 登录前是否要求同意条款 SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox diff --git a/backend/internal/service/risk_control_allowlist_test.go b/backend/internal/service/risk_control_allowlist_test.go new file mode 100644 index 000000000000..8019c1cdd844 --- /dev/null +++ b/backend/internal/service/risk_control_allowlist_test.go @@ -0,0 +1,153 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type failingAllowlistRepo struct { + fakeSettingRepo + failKey string +} + +func (r *failingAllowlistRepo) GetValue(ctx context.Context, key string) (string, error) { + if key == r.failKey || r.failKey == "all" { + return "", errors.New("database unavailable") + } + return r.fakeSettingRepo.GetValue(ctx, key) +} + +func TestRiskControlAllowlistRetainsLastGoodValueAndRetries(t *testing.T) { + for _, failKey := range []string{SettingKeyCyberPolicyUserAllowlist, "all"} { + t.Run(failKey, func(t *testing.T) { + repo := &failingAllowlistRepo{fakeSettingRepo: fakeSettingRepo{vals: map[string]string{SettingKeyCyberPolicyUserAllowlist: "12"}}} + svc := &SettingService{settingRepo: repo} + ctx := context.Background() + require.True(t, svc.IsCyberPolicyUserAllowlisted(ctx, 12)) + expire := func() { + cached, ok := svc.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime) + require.True(t, ok) + old := *cached + old.expiresAt = 0 + svc.cyberSessionBlockRuntimeCache.Store(&old) + } + expire() + repo.failKey = failKey + require.True(t, svc.IsCyberPolicyUserAllowlisted(ctx, 12)) + cached, ok := svc.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime) + require.True(t, ok) + require.LessOrEqual(t, time.Until(time.Unix(0, cached.expiresAt)), cyberSessionBlockRuntimeErrorTTL) + repo.failKey = "" + repo.vals[SettingKeyCyberPolicyUserAllowlist] = "" + expire() + require.False(t, svc.IsCyberPolicyUserAllowlisted(ctx, 12), "successful removal must replace stale membership") + }) + } +} + +func TestRiskControlAllowlistAuditsWithoutLocalPenalties(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{CategoryScores: map[string]float64{"sexual": 0.99}}}}) + })) + defer server.Close() + for _, path := range []string{"keyword", "hash", "pre_block", "observe"} { + t.Run(path, func(t *testing.T) { + cfg := defaultContentModerationConfig() + cfg.Enabled, cfg.AutoBanEnabled, cfg.EmailOnHit = true, true, true + cfg.BanThreshold = 1 + cfg.Mode = ContentModerationModePreBlock + cfg.BaseURL, cfg.APIKeys = server.URL, []string{"sk-test"} + if path == "observe" { + cfg.Mode = ContentModerationModeObserve + } + if path == "keyword" { + cfg.BlockedKeywords = []string{"blocked prompt"} + } + hashes := &contentModerationTestHashCache{hashes: map[string]struct{}{}} + if path == "hash" { + cfg.PreHashCheckEnabled = true + hashes.hashes[(ContentModerationInput{Text: "blocked prompt"}).Hash()] = struct{}{} + } + raw, err := json.Marshal(cfg) + require.NoError(t, err) + repo := &banCountArgsTestRepo{} + svc := &ContentModerationService{ + settingRepo: &contentModerationTestSettingRepo{values: map[string]string{ + SettingKeyRiskControlEnabled: "true", SettingKeyContentModerationConfig: string(raw), SettingKeyCyberPolicyUserAllowlist: "12", + }}, + repo: repo, hashCache: hashes, emailService: &EmailService{}, + httpClient: server.Client(), asyncQueue: make(chan contentModerationTask, 4), keyHealth: make(map[string]*contentModerationKeyHealth), + } + decision, err := svc.Check(context.Background(), ContentModerationCheckInput{ + UserID: 12, UserEmail: "trusted@example.com", Protocol: ContentModerationProtocolOpenAIChat, + Body: []byte(`{"messages":[{"role":"user","content":"blocked prompt"}]}`), + }) + require.NoError(t, err) + require.True(t, decision.Allowed) + require.False(t, decision.Blocked) + var task contentModerationTask + select { + case task = <-svc.asyncQueue: + case <-time.After(time.Second): + t.Fatal("expected an audit task") + } + if task.log != nil { + svc.persistContentModerationLog(context.Background(), task.config, task.log, task.inputHash, task.recordHash, task.applySideEffects) + } else { + delay := 1 + svc.checkSync(context.Background(), task.input, svc.runtimeSnapshot.Load().config, task.content, task.inputHash, &delay, false) + } + logs := repo.snapshotLogs() + require.Len(t, logs, 1) + require.True(t, logs[0].Flagged) + require.Equal(t, ContentModerationModeRiskControlLogOnly, logs[0].Mode) + require.False(t, logs[0].AutoBanned) + require.False(t, logs[0].EmailSent) + require.Zero(t, logs[0].ViolationCount) + require.Empty(t, repo.snapshotCountCalls()) + require.Zero(t, svc.preBlockBlocked.Load()) + if path != "hash" { + require.Empty(t, hashes.hashes) + } + }) + } +} + +type failingModerationAllowlistRepo struct { + contentModerationTestSettingRepo + fail bool +} + +func (r *failingModerationAllowlistRepo) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + if r.fail { + return nil, errors.New("database unavailable") + } + return r.contentModerationTestSettingRepo.GetMultiple(ctx, keys) +} + +func TestRiskControlAllowlistSnapshotRetainsMembershipOnFailure(t *testing.T) { + repo := &failingModerationAllowlistRepo{contentModerationTestSettingRepo: contentModerationTestSettingRepo{values: map[string]string{ + SettingKeyRiskControlEnabled: "true", SettingKeyCyberPolicyUserAllowlist: "12", + }}} + svc := &ContentModerationService{settingRepo: repo} + ctx := context.Background() + _, err := svc.refreshRuntimeSnapshot(ctx) + require.NoError(t, err) + repo.fail = true + _, err = svc.refreshRuntimeSnapshot(ctx) + require.Error(t, err) + require.Contains(t, svc.runtimeSnapshot.Load().allowlistedUsers, int64(12)) + repo.fail = false + repo.values[SettingKeyCyberPolicyUserAllowlist] = "34" + _, err = svc.refreshRuntimeSnapshot(ctx) + require.NoError(t, err) + require.NotContains(t, svc.runtimeSnapshot.Load().allowlistedUsers, int64(12)) + require.Contains(t, svc.runtimeSnapshot.Load().allowlistedUsers, int64(34)) +} diff --git a/backend/internal/service/setting_cyber_allowlist_test.go b/backend/internal/service/setting_cyber_allowlist_test.go new file mode 100644 index 000000000000..7c0c6752acb8 --- /dev/null +++ b/backend/internal/service/setting_cyber_allowlist_test.go @@ -0,0 +1,42 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type cyberAllowlistSettingsRepo struct { + authSourceDefaultsRepoStub +} + +func (r *cyberAllowlistSettingsRepo) GetValue(_ context.Context, key string) (string, error) { + value, ok := r.values[key] + if !ok { + return "", ErrSettingNotFound + } + return value, nil +} + +func TestUpdateSettingsCyberAllowlistRoundTripAndImmediateRefresh(t *testing.T) { + ctx := context.Background() + repo := &cyberAllowlistSettingsRepo{authSourceDefaultsRepoStub{values: map[string]string{}}} + svc := NewSettingService(repo, &config.Config{}) + resetGatewayForwardingSettingsCacheForTest(t) + defer svc.refreshCachedSettings(&SystemSettings{}) + require.False(t, svc.IsCyberPolicyUserAllowlisted(ctx, 12)) + settings := &SystemSettings{CyberPolicyUserAllowlist: "12, 34"} + require.NoError(t, svc.UpdateSettings(ctx, settings)) + require.True(t, svc.IsCyberPolicyUserAllowlisted(ctx, 12)) + require.Equal(t, "12, 34", svc.parseSettings(repo.values).CyberPolicyUserAllowlist) + settings.CyberPolicyUserAllowlist = "12, invalid" + require.Error(t, svc.UpdateSettings(ctx, settings)) + require.Equal(t, "12, 34", repo.values[SettingKeyCyberPolicyUserAllowlist]) + settings.CyberPolicyUserAllowlist = "" + require.NoError(t, svc.UpdateSettings(ctx, settings)) + require.False(t, svc.IsCyberPolicyUserAllowlisted(ctx, 12)) +} diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go index 8f81c1c0bb3c..ec6d21d502aa 100644 --- a/backend/internal/service/setting_gateway_runtime.go +++ b/backend/internal/service/setting_gateway_runtime.go @@ -152,12 +152,13 @@ type cachedCodexRestrictionPolicy struct { expiresAt int64 // unix nano } -// cachedCyberSessionBlockRuntime cyber 会话屏蔽开关+TTL 进程内缓存(60s TTL)。 +// cachedCyberSessionBlockRuntime cyber 会话屏蔽开关、TTL 与用户白名单缓存(60s TTL)。 // GetCyberSessionBlockRuntime 在网关请求热路径上被调用,避免每次访问 DB。 type cachedCyberSessionBlockRuntime struct { - enabled bool - ttl time.Duration - expiresAt int64 // unix nano + allowlistedUsers map[int64]struct{} + enabled bool + ttl time.Duration + expiresAt int64 // unix nano } const cyberSessionBlockRuntimeCacheTTL = 60 * time.Second @@ -172,7 +173,7 @@ const openAIQuotaAutoPauseSettingsRefreshKey = "openai_quota_auto_pause_settings // GetCyberSessionBlockRuntime 返回 (开关, TTL),进程内缓存 ~60s, // 供网关热路径读取时避免 DB 往返。 -// 两个 setting key 在单次 singleflight 里一起读取,减少 DB 往返。 +// 屏蔽设置与用户白名单在单次 singleflight 中一起读取。 // 默认值:开关 false,TTL 1h(与粘性会话对齐)。 func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, time.Duration) { if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil { @@ -181,6 +182,8 @@ func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, } } result, _, _ := s.cyberSessionBlockRuntimeSF.Do("cyber_session_block_runtime", func() (any, error) { + s.cyberSessionBlockRuntimeMu.Lock() + defer s.cyberSessionBlockRuntimeMu.Unlock() if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { return cached, nil @@ -192,15 +195,11 @@ func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, enabledVal, enabledErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockEnabled) ttlVal, ttlErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockTTLSeconds) + previous, _ := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime) + cacheTTL := cyberSessionBlockRuntimeCacheTTL if enabledErr != nil && !errors.Is(enabledErr, ErrSettingNotFound) { slog.Warn("failed to get cyber_session_block_enabled setting", "error", enabledErr) - entry := &cachedCyberSessionBlockRuntime{ - enabled: false, - ttl: time.Hour, - expiresAt: time.Now().Add(cyberSessionBlockRuntimeErrorTTL).UnixNano(), - } - s.cyberSessionBlockRuntimeCache.Store(entry) - return entry, nil + cacheTTL = cyberSessionBlockRuntimeErrorTTL } enabled := enabledErr == nil && strings.TrimSpace(enabledVal) == "true" @@ -212,10 +211,23 @@ func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, } } + allowlistVal, allowlistErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberPolicyUserAllowlist) + var allowlistedUsers map[int64]struct{} + if allowlistErr == nil { + allowlistedUsers, allowlistErr = ParseCyberPolicyUserAllowlist(allowlistVal) + } + if allowlistErr != nil && !errors.Is(allowlistErr, ErrSettingNotFound) { + slog.Warn("failed to load risk control user allowlist", "error", allowlistErr) + cacheTTL = cyberSessionBlockRuntimeErrorTTL + if previous != nil { + allowlistedUsers = previous.allowlistedUsers + } + } entry := &cachedCyberSessionBlockRuntime{ - enabled: enabled, - ttl: ttl, - expiresAt: time.Now().Add(cyberSessionBlockRuntimeCacheTTL).UnixNano(), + allowlistedUsers: allowlistedUsers, + enabled: enabled, + ttl: ttl, + expiresAt: time.Now().Add(cacheTTL).UnixNano(), } s.cyberSessionBlockRuntimeCache.Store(entry) return entry, nil diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 7c62451d333a..f93f4b1b3068 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -220,6 +220,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // cyber 会话屏蔽(默认关闭,TTL 默认 3600s) SettingKeyCyberSessionBlockEnabled: "false", + SettingKeyCyberPolicyUserAllowlist: "", SettingKeyCyberSessionBlockTTLSeconds: "3600", // Claude Code version check (default: empty = disabled) @@ -843,6 +844,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // cyber 会话屏蔽(默认关闭,TTL 默认 3600s) result.CyberSessionBlockEnabled = settings[SettingKeyCyberSessionBlockEnabled] == "true" + result.CyberPolicyUserAllowlist = settings[SettingKeyCyberPolicyUserAllowlist] if v, err := strconv.Atoi(strings.TrimSpace(settings[SettingKeyCyberSessionBlockTTLSeconds])); err == nil && v > 0 { result.CyberSessionBlockTTLSeconds = v } else { diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 6559249ada00..7aea7c8ae98d 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -135,6 +135,7 @@ type SettingService struct { codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy codexRestrictionPolicySF singleflight.Group + cyberSessionBlockRuntimeMu sync.Mutex cyberSessionBlockRuntimeCache atomic.Value // *cachedCyberSessionBlockRuntime cyberSessionBlockRuntimeSF singleflight.Group diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 421b226524a5..caf3dd87e9c0 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -449,6 +449,10 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting // cyber 会话屏蔽开关 + TTL updates[SettingKeyCyberSessionBlockEnabled] = strconv.FormatBool(settings.CyberSessionBlockEnabled) + if _, err := ParseCyberPolicyUserAllowlist(settings.CyberPolicyUserAllowlist); err != nil { + return nil, err + } + updates[SettingKeyCyberPolicyUserAllowlist] = settings.CyberPolicyUserAllowlist if settings.CyberSessionBlockTTLSeconds > 0 { updates[SettingKeyCyberSessionBlockTTLSeconds] = strconv.Itoa(settings.CyberSessionBlockTTLSeconds) } @@ -800,6 +804,11 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { // codex_cli_only 加固策略缓存:设置更新后强制下次重载(涉及 4 个键 + JSON 解析,直接置过期)。 s.codexRestrictionPolicySF.Forget("codex_restriction_policy") s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) + // Retain the successfully saved allowlist if the next DB refresh fails. + s.cyberSessionBlockRuntimeMu.Lock() + allowlistedUsers, _ := ParseCyberPolicyUserAllowlist(settings.CyberPolicyUserAllowlist) + s.cyberSessionBlockRuntimeCache.Store(&cachedCyberSessionBlockRuntime{allowlistedUsers: allowlistedUsers}) + s.cyberSessionBlockRuntimeMu.Unlock() if s.onUpdate != nil { s.onUpdate() // Invalidate cache after settings update } diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 883d680319b5..bf2e840baaff 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -169,6 +169,7 @@ type SystemSettings struct { DefaultBalance float64 RiskControlEnabled bool CyberSessionBlockEnabled bool + CyberPolicyUserAllowlist string CyberSessionBlockTTLSeconds int AffiliateEnabled bool AffiliateRebateRate float64 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 7e551c9c76a9..1fcc0021b190 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -656,6 +656,7 @@ export interface SystemSettings { risk_control_enabled: boolean; // Cyber session block + cyber_policy_user_allowlist: string; cyber_session_block_enabled: boolean; cyber_session_block_ttl_seconds: number; @@ -976,6 +977,7 @@ export interface UpdateSettingsRequest { risk_control_enabled?: boolean; // Cyber session block + cyber_policy_user_allowlist?: string; cyber_session_block_enabled?: boolean; cyber_session_block_ttl_seconds?: number; diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index ced6771230af..680669024cd2 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -84,6 +84,8 @@ export default { enabledHint: 'When off, the admin sidebar entry is hidden and gateway moderation is skipped.', cyberSessionBlock: 'Cyber session auto-block', cyberSessionBlockHint: 'When enabled, sessions hit by upstream cyber_policy are blocked locally for the TTL and no longer forwarded. Only the offending session is blocked; other sessions on the same key are unaffected.', + riskControlUserAllowlist: 'Risk control allowlist', + riskControlUserAllowlistHint: 'Enter any email keyword to search for matching users. Allowlisted users will not trigger account bans or local blocking, but upstream restrictions still apply. This feature is typically used for trusted downstream relays.', cyberSessionBlockTTL: 'Block TTL (seconds)', }, affiliate: { diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 39007377c46f..e31e3dc6a786 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -84,6 +84,8 @@ export default { enabledHint: '关闭后管理员侧边栏入口隐藏,网关内容审计不会执行。', cyberSessionBlock: 'cyber 会话自动屏蔽', cyberSessionBlockHint: '开启后,被上游网络安全策略(cyber_policy)拦截的会话将在 TTL 内被本地屏蔽,不再发往上游。仅屏蔽该会话,不影响同 Key 其他会话。', + riskControlUserAllowlist: '风控白名单', + riskControlUserAllowlistHint: '输入任意邮箱关键词进行模糊搜索。 白名单中的用户不会触发封号或本地屏蔽,但仍然无法突破上游拦截。该功能通常用于可信的下游中转站。', cyberSessionBlockTTL: '屏蔽时长(秒)', }, affiliate: { diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 9f04a341da02..1b1c914ec05e 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -7461,6 +7461,18 @@
+
+ + +

+ {{ t('admin.settings.features.riskControl.riskControlUserAllowlistHint') }} +

+
+
-
- + diff --git a/frontend/src/components/account/ClaudeResetCreditsCell.vue b/frontend/src/components/account/ClaudeResetCreditsCell.vue index f462b8e7203d..248b630745e9 100644 --- a/frontend/src/components/account/ClaudeResetCreditsCell.vue +++ b/frontend/src/components/account/ClaudeResetCreditsCell.vue @@ -1,28 +1,66 @@