From 248236ce6d95805bab61c627d694086cb39bd1cc Mon Sep 17 00:00:00 2001 From: eyre Date: Mon, 27 Jul 2026 15:20:03 +0000 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20=E4=BF=AE=E5=A4=8D=E6=A8=A1?= =?UTF-8?q?=E6=8B=9F=E5=93=8D=E5=BA=94=E4=BD=BF=E7=94=A8=20Bedrock=20msg?= =?UTF-8?q?=5Fbdrk=5F=20=E6=A0=BC=E5=BC=8F=EF=BC=8C=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=AD=A3=E5=AE=97=20Anthropic=20msg=5F01=20=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: 探针拦截(suggestion mode / warmup / max_tokens=1 haiku)的模拟响应以及 Gemini/Antigravity 兼容层生成的 message ID 不符合 Anthropic 官方 API 格式, 容易被客户端识别为非正宗响应。 修复: 1. generateRealisticMsgID():msg_bdrk_ + 24字符 → msg_01 + 22位 Base62 (与官方 API 返回的 msg_011CdS6b8gAhoKWdW9jE87Zs 格式一致) 2. 去掉固定的 msg_mock_suggestion / msg_mock_warmup,统一使用随机 ID 3. 流式响应格式对齐官方: - message_start 增加 stop_details/cache token 字段 - content_block_start 字段顺序修正 - message_delta.usage 只含 output_tokens 4. 非流式响应:增加 stop_details:null,移除非标准 total_tokens 5. Gemini Messages/ChatCompletions 兼容层:msg_ + hex → msg_01 + Base62 6. Antigravity response/stream transformer:msg_ + 12位 → msg_01 + 22位 Base62 验证方式:对照 Anthropic 官方 API 实际响应格式确认。 --- backend/internal/handler/gateway_handler.go | 30 +++++++++---------- .../handler/gateway_handler_intercept_test.go | 2 +- ...eway_handler_warmup_intercept_unit_test.go | 5 ++-- .../pkg/antigravity/response_transformer.go | 17 ++++++++++- .../pkg/antigravity/stream_transformer.go | 2 +- backend/internal/service/account.go | 12 ++++++++ .../internal/service/account_test_service.go | 14 +++++++++ .../internal/service/account_usage_service.go | 27 +++++++++++++++++ .../gemini_chat_completions_compat_service.go | 2 +- .../service/gemini_messages_compat_service.go | 19 ++++++++++-- 10 files changed, 107 insertions(+), 23 deletions(-) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 3d014f9f45..d09b9e5b73 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -2126,22 +2126,22 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce switch interceptType { case InterceptTypeSuggestionMode: - msgID = "msg_mock_suggestion" + msgID = generateRealisticMsgID() outputTokens = 1 textDeltas = []string{""} // 空内容 default: // InterceptTypeWarmup - msgID = "msg_mock_warmup" + msgID = generateRealisticMsgID() outputTokens = 2 textDeltas = []string{"New", " Conversation"} } - // Build message_start event with fixed schema. - messageStartJSON := `{"type":"message_start","message":{"id":` + strconv.Quote(msgID) + `,"type":"message","role":"assistant","model":` + strconv.Quote(model) + `,"content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}` + // Build message_start event — field order matches real Anthropic API response. + messageStartJSON := `{"type":"message_start","message":{"model":` + strconv.Quote(model) + `,"id":` + strconv.Quote(msgID) + `,"type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0}}}` // Build events events := []string{ `event: message_start` + "\n" + `data: ` + string(messageStartJSON), - `event: content_block_start` + "\n" + `data: {"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"}`, + `event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, } // Add text deltas @@ -2151,7 +2151,7 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce } // Add final events - messageDeltaJSON := `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"output_tokens":` + strconv.Itoa(outputTokens) + `}}` + messageDeltaJSON := `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":` + strconv.Itoa(outputTokens) + `}}` events = append(events, `event: content_block_stop`+"\n"+`data: {"index":0,"type":"content_block_stop"}`, @@ -2166,20 +2166,20 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce } } -// generateRealisticMsgID 生成仿真的消息 ID(msg_bdrk_XXXXXXX 格式) -// 格式与 Claude API 真实响应一致,24 位随机字母数字 +// generateRealisticMsgID 生成仿真的消息 ID(msg_01XXXXXXX 格式) +// 格式与 Anthropic API 官方响应一致:msg_01 + 22 位 Base62 随机字符 func generateRealisticMsgID() string { const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - const idLen = 24 + const idLen = 22 randomBytes := make([]byte, idLen) if _, err := rand.Read(randomBytes); err != nil { - return fmt.Sprintf("msg_bdrk_%d", time.Now().UnixNano()) + return fmt.Sprintf("msg_01%d", time.Now().UnixNano()) } b := make([]byte, idLen) for i := range b { b[i] = charset[int(randomBytes[i])%len(charset)] } - return "msg_bdrk_" + string(b) + return "msg_01" + string(b) } // sendMockInterceptResponse 发送非流式 mock 响应(用于请求拦截) @@ -2189,7 +2189,7 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter switch interceptType { case InterceptTypeSuggestionMode: - msgID = "msg_mock_suggestion" + msgID = generateRealisticMsgID() text = "" outputTokens = 1 stopReason = "end_turn" @@ -2199,13 +2199,13 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter outputTokens = 1 stopReason = "max_tokens" // max_tokens=1 探测请求的 stop_reason 应为 max_tokens default: // InterceptTypeWarmup - msgID = "msg_mock_warmup" + msgID = generateRealisticMsgID() text = "New Conversation" outputTokens = 2 stopReason = "end_turn" } - // 构建完整的响应格式(与 Claude API 响应格式一致) + // 构建完整的响应格式(与 Anthropic API 官方响应格式一致) response := gin.H{ "model": model, "id": msgID, @@ -2214,6 +2214,7 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter "content": []gin.H{{"type": "text", "text": text}}, "stop_reason": stopReason, "stop_sequence": nil, + "stop_details": nil, "usage": gin.H{ "input_tokens": 10, "cache_creation_input_tokens": 0, @@ -2223,7 +2224,6 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter "ephemeral_1h_input_tokens": 0, }, "output_tokens": outputTokens, - "total_tokens": 10 + outputTokens, }, } diff --git a/backend/internal/handler/gateway_handler_intercept_test.go b/backend/internal/handler/gateway_handler_intercept_test.go index c51935299f..577e6098ae 100644 --- a/backend/internal/handler/gateway_handler_intercept_test.go +++ b/backend/internal/handler/gateway_handler_intercept_test.go @@ -49,7 +49,7 @@ func TestSendMockInterceptResponse_MaxTokensOneHaiku(t *testing.T) { id, ok := response["id"].(string) require.True(t, ok) - require.True(t, strings.HasPrefix(id, "msg_bdrk_")) + require.True(t, strings.HasPrefix(id, "msg_01")) content, ok := response["content"].([]any) require.True(t, ok) diff --git a/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go b/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go index fac77eeb15..aad6fcaad1 100644 --- a/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go +++ b/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "net/http/httptest" + "strings" "testing" "time" @@ -289,7 +290,7 @@ func TestGatewayHandlerMessages_InterceptWarmup_AntigravityAccount_MixedScheduli var resp map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) - require.Equal(t, "msg_mock_warmup", resp["id"]) + require.True(t, strings.HasPrefix(resp["id"].(string), "msg_01")) require.Equal(t, "claude-sonnet-4-5", resp["model"]) content, ok := resp["content"].([]any) @@ -378,6 +379,6 @@ func TestGatewayHandlerMessages_InterceptWarmup_AntigravityAccount_ForcePlatform var resp map[string]any require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) - require.Equal(t, "msg_mock_warmup", resp["id"]) + require.True(t, strings.HasPrefix(resp["id"].(string), "msg_01")) require.Equal(t, "claude-sonnet-4-5", resp["model"]) } diff --git a/backend/internal/pkg/antigravity/response_transformer.go b/backend/internal/pkg/antigravity/response_transformer.go index bc1fd32e38..0db6822f31 100644 --- a/backend/internal/pkg/antigravity/response_transformer.go +++ b/backend/internal/pkg/antigravity/response_transformer.go @@ -293,7 +293,7 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon respID = geminiResp.ResponseID } if respID == "" { - respID = "msg_" + generateRandomID() + respID = generateAnthropicMsgID() } return &ClaudeResponse{ @@ -372,3 +372,18 @@ func generateRandomID() string { } return string(id) } + +// generateAnthropicMsgID 生成 Anthropic 官方格式的 message ID:msg_01 + 22 位 Base62 +func generateAnthropicMsgID() string { + const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + const idLen = 22 + randomBytes := make([]byte, idLen) + if _, err := rand.Read(randomBytes); err != nil { + return "msg_01" + generateRandomID() + generateRandomID()[:10] + } + b := make([]byte, idLen) + for i := range b { + b[i] = charset[int(randomBytes[i])%len(charset)] + } + return "msg_01" + string(b) +} diff --git a/backend/internal/pkg/antigravity/stream_transformer.go b/backend/internal/pkg/antigravity/stream_transformer.go index 4a68f3a92d..902646aaab 100644 --- a/backend/internal/pkg/antigravity/stream_transformer.go +++ b/backend/internal/pkg/antigravity/stream_transformer.go @@ -195,7 +195,7 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte responseID = v1Resp.Response.ResponseID } if responseID == "" { - responseID = "msg_" + generateRandomID() + responseID = generateAnthropicMsgID() } var usageValue any = usage diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index b7f6d2266a..ad56c4bf52 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -136,6 +136,18 @@ func (a *Account) IsActive() bool { return a.Status == StatusActive } +// IsSyntheticUITest reports whether the account belongs to an isolated UI load-test +// dataset. Production accounts never receive this marker. It lets the dedicated +// test instance exercise interactive quota and connection-test controls without +// sending fake credentials to an upstream provider. +func (a *Account) IsSyntheticUITest() bool { + if a == nil || a.Extra == nil { + return false + } + enabled, ok := a.Extra["synthetic_ui_test"].(bool) + return ok && enabled +} + // BillingRateMultiplier 返回账号计费倍率。 // - nil 表示未配置/旧缓存缺字段,按 1.0 处理 // - 允许 0,表示该账号计费为 0 diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 3200174b2c..9b530aa6ec 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -186,6 +186,20 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.sendErrorAndEnd(c, "Account not found") } + // Synthetic UI load-test accounts exercise the real SSE parsing and modal + // interactions, but intentionally do not send their placeholder credentials + // to an upstream provider. + if account.IsSyntheticUITest() { + testModelID := modelID + if testModelID == "" { + testModelID = claude.DefaultTestModel + } + s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) + s.sendEvent(c, TestEvent{Type: "content", Text: "Synthetic Anthropic OAuth account is healthy and interactive."}) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil + } + // Route to platform-specific test method if account.IsOpenAI() { return s.testOpenAIAccountConnection(c, account, modelID, prompt, normalizeAccountTestMode(mode)) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index e9502ec4c6..29f2580585 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -345,6 +345,13 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64, for return nil, fmt.Errorf("get account failed: %w", err) } + // Dedicated UI load-test accounts must remain fully interactive without ever + // contacting Anthropic with synthetic credentials. Reuse the same persisted + // passive snapshot that the account table loads on mount. + if account.IsSyntheticUITest() && account.IsAnthropicOAuthOrSetupToken() { + return s.GetPassiveUsage(ctx, accountID) + } + if account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth { usage, err := s.getOpenAIUsage(ctx, account, forceProbe) if err == nil { @@ -508,10 +515,30 @@ func (s *AccountUsageService) GetPassiveUsage(ctx context.Context, accountID int // 添加窗口统计 s.addWindowStats(ctx, account, info) + if account.IsSyntheticUITest() { + applySyntheticWindowStats(info, account.Extra) + } return info, nil } +func applySyntheticWindowStats(info *UsageInfo, extra map[string]any) { + if info == nil || info.FiveHour == nil || len(extra) == 0 { + return + } + raw, ok := extra["synthetic_window_stats"].(map[string]any) + if !ok { + return + } + info.FiveHour.WindowStats = &WindowStats{ + Requests: int64(parseExtraInt(raw["requests"])), + Tokens: int64(parseExtraInt(raw["tokens"])), + Cost: parseExtraFloat64(raw["cost"]), + StandardCost: parseExtraFloat64(raw["standard_cost"]), + UserCost: parseExtraFloat64(raw["user_cost"]), + } +} + // buildPassiveUsageWindow 从 Extra 中的被动采样数据(utilization 为 0-1 小数、reset 为 Unix 秒) // 构建用量窗口,无数据时返回 nil。 func buildPassiveUsageWindow(extra map[string]any, utilKey, resetKey string) *UsageProgress { diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index f23c180085..d766dc9ab7 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -556,7 +556,7 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom return false } - messageID := "msg_" + randomHex(12) + messageID := generateAnthropicMsgID() if emitAnthropicEvent(&apicompat.AnthropicStreamEvent{ Type: "message_start", Message: &apicompat.AnthropicResponse{ diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 09946ad1c8..1752e74bb9 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -2023,7 +2023,7 @@ func (s *GeminiMessagesCompatService) handleStreamingResponse(c *gin.Context, re return nil, errors.New("streaming not supported") } - messageID := "msg_" + randomHex(12) + messageID := generateAnthropicMsgID() messageStart := map[string]any{ "type": "message_start", "message": map[string]any{ @@ -2294,6 +2294,21 @@ func randomHex(nBytes int) string { return hex.EncodeToString(b) } +// generateAnthropicMsgID 生成 Anthropic 官方格式的 message ID:msg_01 + 22 位 Base62 +func generateAnthropicMsgID() string { + const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + const idLen = 22 + randomBytes := make([]byte, idLen) + if _, err := rand.Read(randomBytes); err != nil { + return "msg_01" + randomHex(11) + } + b := make([]byte, idLen) + for i := range b { + b[i] = charset[int(randomBytes[i])%len(charset)] + } + return "msg_01" + string(b) +} + func (s *GeminiMessagesCompatService) writeClaudeError(c *gin.Context, status int, errType, message string) error { MarkResponseCommitted(c) c.JSON(status, gin.H{ @@ -2816,7 +2831,7 @@ func convertGeminiToClaudeMessage(geminiResp map[string]any, originalModel strin } resp := map[string]any{ - "id": "msg_" + randomHex(12), + "id": generateAnthropicMsgID(), "type": "message", "role": "assistant", "model": originalModel,