mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
Merge pull request #4975 from Vibeone/fix/msg-id-format-authentic
fix(gateway): 修复模拟响应 message ID 格式,改为正宗 Anthropic msg_01 格式
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user