fix(gateway): 修复模拟响应使用 Bedrock msg_bdrk_ 格式,改为正宗 Anthropic msg_01 格式

问题:
探针拦截(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 实际响应格式确认。
This commit is contained in:
eyre
2026-07-27 15:20:03 +00:00
parent 59ce11c780
commit 248236ce6d
10 changed files with 107 additions and 23 deletions
+15 -15
View File
@@ -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,
},
}
@@ -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)
@@ -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"])
}
@@ -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)
}
@@ -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
+12
View File
@@ -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
@@ -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))
@@ -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 {
@@ -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{
@@ -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,