diff --git a/.gitignore b/.gitignore index 6d636c8d..c33cde99 100644 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,4 @@ docs/ code-reviews/ AGENTS.md backend/cmd/server/server +deploy/docker-compose.override.yml diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 83b87a32..3af6579c 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -20,12 +20,18 @@ func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel st // 检测是否启用 thinking requestedThinkingEnabled := claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled" - // 为避免 Claude 模型的 thought signature/消息块约束导致 400(上游要求 thinking 块开头等), - // 非 Gemini 模型默认不启用 thinking(除非未来支持完整签名链路)。 - isThinkingEnabled := requestedThinkingEnabled && allowDummyThought + // antigravity(v1internal) 下,Gemini 与 Claude 的 “thinking” 都可能涉及 thoughtSignature 链路: + // - Gemini:支持 dummy signature 跳过校验 + // - Claude:需要透传上游签名(否则容易 400) + isThinkingEnabled := requestedThinkingEnabled + + thoughtSignatureMode := thoughtSignatureModePreserve + if allowDummyThought { + thoughtSignatureMode = thoughtSignatureModeDummy + } // 1. 构建 contents - contents, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, allowDummyThought) + contents, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, thoughtSignatureMode) if err != nil { return nil, fmt.Errorf("build contents: %w", err) } @@ -34,15 +40,7 @@ func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel st systemInstruction := buildSystemInstruction(claudeReq.System, claudeReq.Model) // 3. 构建 generationConfig - reqForGen := claudeReq - if requestedThinkingEnabled && !allowDummyThought { - log.Printf("[Warning] Disabling thinking for non-Gemini model in antigravity transform: model=%s", mappedModel) - // shallow copy to avoid mutating caller's request - clone := *claudeReq - clone.Thinking = nil - reqForGen = &clone - } - generationConfig := buildGenerationConfig(reqForGen) + generationConfig := buildGenerationConfig(claudeReq) // 4. 构建 tools tools := buildTools(claudeReq.Tools) @@ -131,7 +129,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string) *GeminiCon } // buildContents 构建 contents -func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isThinkingEnabled, allowDummyThought bool) ([]GeminiContent, error) { +func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isThinkingEnabled bool, thoughtSignatureMode thoughtSignatureMode) ([]GeminiContent, error) { var contents []GeminiContent for i, msg := range messages { @@ -140,11 +138,13 @@ func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isT role = "model" } - parts, err := buildParts(msg.Content, toolIDToName, allowDummyThought) + parts, err := buildParts(msg.Content, toolIDToName, thoughtSignatureMode) if err != nil { return nil, fmt.Errorf("build parts for message %d: %w", i, err) } + allowDummyThought := thoughtSignatureMode == thoughtSignatureModeDummy + // 只有 Gemini 模型支持 dummy thinking block workaround // 只对最后一条 assistant 消息添加(Pre-fill 场景) // 历史 assistant 消息不能添加没有 signature 的 dummy thinking block @@ -183,37 +183,19 @@ func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isT // 参考: https://ai.google.dev/gemini-api/docs/thought-signatures const dummyThoughtSignature = "skip_thought_signature_validator" -// isValidThoughtSignature 验证 thought signature 是否有效 -// Claude API 要求 signature 必须是 base64 编码的字符串,长度至少 32 字节 -func isValidThoughtSignature(signature string) bool { - // 空字符串无效 - if signature == "" { - return false - } +// buildParts 构建消息的 parts +type thoughtSignatureMode int - // signature 应该是 base64 编码,长度至少 40 个字符(约 30 字节) - // 参考 Claude API 文档和实际观察到的有效 signature - if len(signature) < 40 { - log.Printf("[Debug] Signature too short: len=%d", len(signature)) - return false - } - - // 检查是否是有效的 base64 字符 - // base64 字符集: A-Z, a-z, 0-9, +, /, = - for i, c := range signature { - if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && - (c < '0' || c > '9') && c != '+' && c != '/' && c != '=' { - log.Printf("[Debug] Invalid base64 character at position %d: %c (code=%d)", i, c, c) - return false - } - } - - return true -} +const ( + thoughtSignatureModePreserve thoughtSignatureMode = iota + thoughtSignatureModeDummy +) // buildParts 构建消息的 parts -// allowDummyThought: 只有 Gemini 模型支持 dummy thought signature -func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDummyThought bool) ([]GeminiPart, error) { +// thoughtSignatureMode: +// - dummy: 用 dummy signature 跳过 Gemini thoughtSignature 校验 +// - preserve: 透传输入中的 signature(主要用于 Claude via Vertex 的签名链路) +func buildParts(content json.RawMessage, toolIDToName map[string]string, thoughtSignatureMode thoughtSignatureMode) ([]GeminiPart, error) { var parts []GeminiPart // 尝试解析为字符串 @@ -239,7 +221,9 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu } case "thinking": - if allowDummyThought { + signature := strings.TrimSpace(block.Signature) + + if thoughtSignatureMode == thoughtSignatureModeDummy { // Gemini 模型可以使用 dummy signature parts = append(parts, GeminiPart{ Text: block.Thinking, @@ -249,20 +233,27 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu continue } - // Claude 模型:仅在提供有效 signature 时保留 thinking block;否则跳过以避免上游校验失败。 - signature := strings.TrimSpace(block.Signature) + // Claude via Vertex: + // - signature 是上游返回的完整性令牌;本地不需要/无法验证,只能透传 + // - 缺失/无效 signature(例如来自 Gemini 的 dummy signature)会导致上游 400 if signature == "" || signature == dummyThoughtSignature { - log.Printf("[Warning] Skipping thinking block for Claude model (missing or dummy signature)") continue } - if !isValidThoughtSignature(signature) { - log.Printf("[Debug] Thinking signature may be invalid (passing through anyway): len=%d", len(signature)) + + // 兼容:用 Claude 的 "thinking" 块承载两类东西 + // 1) 真正的 thought 文本(thinking != "")-> Gemini thought part + // 2) 仅承载 signature 的空 thinking 块(thinking == "")-> Gemini signature-only part + if strings.TrimSpace(block.Thinking) == "" { + parts = append(parts, GeminiPart{ + ThoughtSignature: signature, + }) + } else { + parts = append(parts, GeminiPart{ + Text: block.Thinking, + Thought: true, + ThoughtSignature: signature, + }) } - parts = append(parts, GeminiPart{ - Text: block.Thinking, - Thought: true, - ThoughtSignature: signature, - }) case "image": if block.Source != nil && block.Source.Type == "base64" { @@ -287,10 +278,15 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu ID: block.ID, }, } - // 只有 Gemini 模型使用 dummy signature - // Claude 模型不设置 signature(避免验证问题) - if allowDummyThought { + switch thoughtSignatureMode { + case thoughtSignatureModeDummy: part.ThoughtSignature = dummyThoughtSignature + case thoughtSignatureModePreserve: + // Claude via Vertex:透传 tool_use 的 signature(如果有) + // 注意:跨模型混用时可能出现 dummy signature,这里直接丢弃以避免 400。 + if sig := strings.TrimSpace(block.Signature); sig != "" && sig != dummyThoughtSignature { + part.ThoughtSignature = sig + } } parts = append(parts, part) diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index 56eebad0..845ae033 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -8,11 +8,11 @@ import ( // TestBuildParts_ThinkingBlockWithoutSignature 测试thinking block无signature时的处理 func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) { tests := []struct { - name string - content string - allowDummyThought bool - expectedParts int - description string + name string + content string + thoughtMode thoughtSignatureMode + expectedParts int + description string }{ { name: "Claude model - skip thinking block without signature", @@ -21,20 +21,20 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) { {"type": "thinking", "thinking": "Let me think...", "signature": ""}, {"type": "text", "text": "World"} ]`, - allowDummyThought: false, - expectedParts: 2, // 只有两个text block - description: "Claude模型应该跳过无signature的thinking block", + thoughtMode: thoughtSignatureModePreserve, + expectedParts: 2, // 只有两个text block + description: "Claude模型应该跳过无signature的thinking block", }, { - name: "Claude model - keep thinking block with signature", + name: "Claude model - preserve thinking block with signature", content: `[ {"type": "text", "text": "Hello"}, - {"type": "thinking", "thinking": "Let me think...", "signature": "valid_sig"}, + {"type": "thinking", "thinking": "Let me think...", "signature": "sig_real_123"}, {"type": "text", "text": "World"} ]`, - allowDummyThought: false, - expectedParts: 3, // 三个block都保留 - description: "Claude模型应该保留有signature的thinking block", + thoughtMode: thoughtSignatureModePreserve, + expectedParts: 3, + description: "Claude模型应透传带 signature 的 thinking block(用于 Vertex 签名链路)", }, { name: "Gemini model - use dummy signature", @@ -43,16 +43,27 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) { {"type": "thinking", "thinking": "Let me think...", "signature": ""}, {"type": "text", "text": "World"} ]`, - allowDummyThought: true, - expectedParts: 3, // 三个block都保留,thinking使用dummy signature - description: "Gemini模型应该为无signature的thinking block使用dummy signature", + thoughtMode: thoughtSignatureModeDummy, + expectedParts: 3, // 三个block都保留,thinking使用dummy signature + description: "Gemini模型应该为无signature的thinking block使用dummy signature", + }, + { + name: "Claude model - signature-only thinking block becomes signature-only part", + content: `[ + {"type": "text", "text": "Hello"}, + {"type": "thinking", "thinking": "", "signature": "sig_only_456"}, + {"type": "text", "text": "World"} + ]`, + thoughtMode: thoughtSignatureModePreserve, + expectedParts: 3, + description: "Claude模型应将空 thinking + signature 映射为 signature-only part,便于 roundtrip", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { toolIDToName := make(map[string]string) - parts, err := buildParts(json.RawMessage(tt.content), toolIDToName, tt.allowDummyThought) + parts, err := buildParts(json.RawMessage(tt.content), toolIDToName, tt.thoughtMode) if err != nil { t.Fatalf("buildParts() error = %v", err) @@ -61,10 +72,71 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) { if len(parts) != tt.expectedParts { t.Errorf("%s: got %d parts, want %d parts", tt.description, len(parts), tt.expectedParts) } + + switch tt.name { + case "Claude model - preserve thinking block with signature": + if len(parts) != 3 { + t.Fatalf("expected 3 parts, got %d", len(parts)) + } + if !parts[1].Thought || parts[1].ThoughtSignature != "sig_real_123" { + t.Fatalf("expected thought part with signature sig_real_123, got thought=%v signature=%q", + parts[1].Thought, parts[1].ThoughtSignature) + } + case "Claude model - signature-only thinking block becomes signature-only part": + if len(parts) != 3 { + t.Fatalf("expected 3 parts, got %d", len(parts)) + } + if parts[1].Thought || parts[1].Text != "" || parts[1].ThoughtSignature != "sig_only_456" { + t.Fatalf("expected signature-only part, got thought=%v text=%q signature=%q", + parts[1].Thought, parts[1].Text, parts[1].ThoughtSignature) + } + case "Gemini model - use dummy signature": + if len(parts) != 3 { + t.Fatalf("expected 3 parts, got %d", len(parts)) + } + if !parts[1].Thought || parts[1].ThoughtSignature != dummyThoughtSignature { + t.Fatalf("expected dummy thought signature, got thought=%v signature=%q", + parts[1].Thought, parts[1].ThoughtSignature) + } + } }) } } +func TestBuildParts_ToolUseSignatureHandling(t *testing.T) { + content := `[ + {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}, "signature": "sig_tool_abc"} + ]` + + t.Run("Claude preserve tool_use signature", func(t *testing.T) { + toolIDToName := make(map[string]string) + parts, err := buildParts(json.RawMessage(content), toolIDToName, thoughtSignatureModePreserve) + if err != nil { + t.Fatalf("buildParts() error = %v", err) + } + if len(parts) != 1 || parts[0].FunctionCall == nil { + t.Fatalf("expected 1 functionCall part, got %+v", parts) + } + if parts[0].ThoughtSignature != "sig_tool_abc" { + t.Fatalf("expected tool signature sig_tool_abc, got %q", parts[0].ThoughtSignature) + } + }) + + t.Run("Gemini uses dummy tool_use signature", func(t *testing.T) { + toolIDToName := make(map[string]string) + parts, err := buildParts(json.RawMessage(content), toolIDToName, thoughtSignatureModeDummy) + if err != nil { + t.Fatalf("buildParts() error = %v", err) + } + if len(parts) != 1 || parts[0].FunctionCall == nil { + t.Fatalf("expected 1 functionCall part, got %+v", parts) + } + if parts[0].ThoughtSignature != dummyThoughtSignature { + t.Fatalf("expected dummy tool signature %q, got %q", dummyThoughtSignature, parts[0].ThoughtSignature) + } + }) +} + // TestBuildTools_CustomTypeTools 测试custom类型工具转换 func TestBuildTools_CustomTypeTools(t *testing.T) { tests := []struct { diff --git a/backend/internal/pkg/geminicli/constants.go b/backend/internal/pkg/geminicli/constants.go index 63f48727..14cfa3a1 100644 --- a/backend/internal/pkg/geminicli/constants.go +++ b/backend/internal/pkg/geminicli/constants.go @@ -26,6 +26,10 @@ const ( // https://www.googleapis.com/auth/generative-language.retriever (often with cloud-platform). DefaultAIStudioScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever" + // DefaultScopes for Google One (personal Google accounts with Gemini access) + // Includes generative-language for Gemini API access and drive.readonly for storage tier detection + DefaultGoogleOneScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" + // GeminiCLIRedirectURI is the redirect URI used by Gemini CLI for Code Assist OAuth. GeminiCLIRedirectURI = "https://codeassist.google.com/authcode" diff --git a/backend/internal/pkg/geminicli/oauth.go b/backend/internal/pkg/geminicli/oauth.go index f93d99b9..c75b3dc5 100644 --- a/backend/internal/pkg/geminicli/oauth.go +++ b/backend/internal/pkg/geminicli/oauth.go @@ -172,14 +172,19 @@ func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error if effective.Scopes == "" { // Use different default scopes based on OAuth type - if oauthType == "ai_studio" { + switch oauthType { + case "ai_studio": // Built-in client can't request some AI Studio scopes (notably generative-language). if isBuiltinClient { effective.Scopes = DefaultCodeAssistScopes } else { effective.Scopes = DefaultAIStudioScopes } - } else { + case "google_one": + // Google One accounts need generative-language scope for Gemini API access + // and drive.readonly scope for storage tier detection + effective.Scopes = DefaultGoogleOneScopes + default: // Default to Code Assist scopes effective.Scopes = DefaultCodeAssistScopes } diff --git a/backend/internal/repository/gemini_oauth_client.go b/backend/internal/repository/gemini_oauth_client.go index bac8736b..b1c86853 100644 --- a/backend/internal/repository/gemini_oauth_client.go +++ b/backend/internal/repository/gemini_oauth_client.go @@ -30,13 +30,14 @@ func (c *geminiOAuthClient) ExchangeCode(ctx context.Context, oauthType, code, c // Use different OAuth clients based on oauthType: // - code_assist: always use built-in Gemini CLI OAuth client (public) + // - google_one: same as code_assist, uses built-in client for personal Google accounts // - ai_studio: requires a user-provided OAuth client oauthCfgInput := geminicli.OAuthConfig{ ClientID: c.cfg.Gemini.OAuth.ClientID, ClientSecret: c.cfg.Gemini.OAuth.ClientSecret, Scopes: c.cfg.Gemini.OAuth.Scopes, } - if oauthType == "code_assist" { + if oauthType == "code_assist" || oauthType == "google_one" { oauthCfgInput.ClientID = "" oauthCfgInput.ClientSecret = "" } @@ -77,7 +78,7 @@ func (c *geminiOAuthClient) RefreshToken(ctx context.Context, oauthType, refresh ClientSecret: c.cfg.Gemini.OAuth.ClientSecret, Scopes: c.cfg.Gemini.OAuth.Scopes, } - if oauthType == "code_assist" { + if oauthType == "code_assist" || oauthType == "google_one" { oauthCfgInput.ClientID = "" oauthCfgInput.ClientSecret = "" } diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 267d7548..be908189 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -307,6 +307,74 @@ func (s *AntigravityGatewayService) unwrapV1InternalResponse(body []byte) ([]byt return body, nil } +// isSignatureRelatedError 检测是否为 signature 相关的 400 错误 +func isSignatureRelatedError(statusCode int, body []byte) bool { + if statusCode != 400 { + return false + } + + bodyStr := strings.ToLower(string(body)) + keywords := []string{ + "signature", + "thought_signature", + "thoughtsignature", + "thinking", + "invalid signature", + "signature validation", + } + + for _, keyword := range keywords { + if strings.Contains(bodyStr, keyword) { + return true + } + } + return false +} + +// stripThinkingFromClaudeRequest 从 Claude 请求中移除所有 thinking 相关内容 +func stripThinkingFromClaudeRequest(req *antigravity.ClaudeRequest) *antigravity.ClaudeRequest { + // 创建副本 + stripped := *req + + // 移除 thinking 配置 + stripped.Thinking = nil + + // 移除消息中的 thinking 块 + if len(stripped.Messages) > 0 { + newMessages := make([]antigravity.ClaudeMessage, 0, len(stripped.Messages)) + for _, msg := range stripped.Messages { + newMsg := msg + + // 如果 content 是数组,过滤 thinking 块 + var blocks []map[string]any + if err := json.Unmarshal(msg.Content, &blocks); err == nil { + filtered := make([]map[string]any, 0, len(blocks)) + for _, block := range blocks { + // 跳过有 type="thinking" 的块 + if blockType, ok := block["type"].(string); ok && blockType == "thinking" { + continue + } + // 跳过没有 type 但有 thinking 字段的块(untyped thinking blocks) + if _, hasType := block["type"]; !hasType { + if _, hasThinking := block["thinking"]; hasThinking { + continue + } + } + filtered = append(filtered, block) + } + if newContent, err := json.Marshal(filtered); err == nil { + newMsg.Content = newContent + } + } + + newMessages = append(newMessages, newMsg) + } + stripped.Messages = newMessages + } + + return &stripped +} + // Forward 转发 Claude 协议请求(Claude → Gemini 转换) func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { startTime := time.Now() @@ -414,11 +482,70 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) s.handleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) - if s.shouldFailoverUpstreamError(resp.StatusCode) { - return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} + // Auto 模式:检测 signature 错误并自动降级重试 + if isSignatureRelatedError(resp.StatusCode, respBody) && claudeReq.Thinking != nil { + log.Printf("[Antigravity] Detected signature-related error, retrying without thinking blocks (account: %s, model: %s)", account.Name, mappedModel) + + // 关闭原始响应,释放连接(respBody 已读取到内存) + _ = resp.Body.Close() + + // 移除 thinking 块并重试一次 + strippedReq := stripThinkingFromClaudeRequest(&claudeReq) + strippedBody, err := antigravity.TransformClaudeToGemini(strippedReq, projectID, mappedModel) + if err != nil { + log.Printf("[Antigravity] Failed to transform stripped request: %v", err) + // 降级失败,返回原始错误 + if s.shouldFailoverUpstreamError(resp.StatusCode) { + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} + } + return nil, s.writeMappedClaudeError(c, resp.StatusCode, respBody) + } + + // 发送降级请求 + retryReq, err := antigravity.NewAPIRequest(ctx, action, accessToken, strippedBody) + if err != nil { + log.Printf("[Antigravity] Failed to create retry request: %v", err) + if s.shouldFailoverUpstreamError(resp.StatusCode) { + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} + } + return nil, s.writeMappedClaudeError(c, resp.StatusCode, respBody) + } + + retryResp, err := s.httpUpstream.Do(retryReq, proxyURL, account.ID, account.Concurrency) + if err != nil { + log.Printf("[Antigravity] Retry request failed: %v", err) + if s.shouldFailoverUpstreamError(resp.StatusCode) { + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} + } + return nil, s.writeMappedClaudeError(c, resp.StatusCode, respBody) + } + + // 如果重试成功,使用重试的响应(不要 return,让后面的代码处理响应) + if retryResp.StatusCode < 400 { + log.Printf("[Antigravity] Retry succeeded after stripping thinking blocks (account: %s, model: %s)", account.Name, mappedModel) + resp = retryResp + } else { + // 重试也失败,返回重试的错误 + retryRespBody, _ := io.ReadAll(io.LimitReader(retryResp.Body, 2<<20)) + _ = retryResp.Body.Close() + log.Printf("[Antigravity] Retry also failed with status %d: %s", retryResp.StatusCode, string(retryRespBody)) + s.handleUpstreamError(ctx, account, retryResp.StatusCode, retryResp.Header, retryRespBody) + + if s.shouldFailoverUpstreamError(retryResp.StatusCode) { + return nil, &UpstreamFailoverError{StatusCode: retryResp.StatusCode} + } + return nil, s.writeMappedClaudeError(c, retryResp.StatusCode, retryRespBody) + } } - return nil, s.writeMappedClaudeError(c, resp.StatusCode, respBody) + // 不是 signature 错误,或者已经没有 thinking 块,直接返回错误 + if resp.StatusCode >= 400 { + if s.shouldFailoverUpstreamError(resp.StatusCode) { + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} + } + + return nil, s.writeMappedClaudeError(c, resp.StatusCode, respBody) + } } requestID := resp.Header.Get("x-request-id") diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index fbec1371..32e9ffba 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "encoding/json" "fmt" ) @@ -70,3 +71,85 @@ func ParseGatewayRequest(body []byte) (*ParsedRequest, error) { return parsed, nil } + +// FilterThinkingBlocks removes thinking blocks from request body +// Returns filtered body or original body if filtering fails (fail-safe) +// This prevents 400 errors from invalid thinking block signatures +func FilterThinkingBlocks(body []byte) []byte { + // Fast path: if body doesn't contain "thinking", skip parsing + if !bytes.Contains(body, []byte("thinking")) { + return body + } + + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return body // Return original on parse error + } + + messages, ok := req["messages"].([]any) + if !ok { + return body // No messages array + } + + filtered := false + for _, msg := range messages { + msgMap, ok := msg.(map[string]any) + if !ok { + continue + } + + content, ok := msgMap["content"].([]any) + if !ok { + continue + } + + // Filter thinking blocks from content array + newContent := make([]any, 0, len(content)) + filteredThisMessage := false + for _, block := range content { + blockMap, ok := block.(map[string]any) + if !ok { + newContent = append(newContent, block) + continue + } + + blockType, _ := blockMap["type"].(string) + // Explicit Anthropic-style thinking block: {"type":"thinking", ...} + if blockType == "thinking" { + filtered = true + filteredThisMessage = true + continue // Skip thinking blocks + } + + // Some clients send the "thinking" object without a "type" discriminator. + // Vertex/Claude still expects a signature for any thinking block, so we drop it. + // We intentionally do not drop other typed blocks (e.g. tool_use) that might + // legitimately contain a "thinking" key inside their payload. + if blockType == "" { + if _, hasThinking := blockMap["thinking"]; hasThinking { + filtered = true + filteredThisMessage = true + continue // Skip thinking blocks + } + } + + newContent = append(newContent, block) + } + + if filteredThisMessage { + msgMap["content"] = newContent + } + } + + if !filtered { + return body // No changes needed + } + + // Re-serialize + newBody, err := json.Marshal(req) + if err != nil { + return body // Return original on marshal error + } + + return newBody +} diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index 5d411e2c..eb8af1da 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "testing" "github.com/stretchr/testify/require" @@ -38,3 +39,115 @@ func TestParseGatewayRequest_InvalidStreamType(t *testing.T) { _, err := ParseGatewayRequest(body) require.Error(t, err) } + +func TestFilterThinkingBlocks(t *testing.T) { + containsThinkingBlock := func(body []byte) bool { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return false + } + messages, ok := req["messages"].([]any) + if !ok { + return false + } + for _, msg := range messages { + msgMap, ok := msg.(map[string]any) + if !ok { + continue + } + content, ok := msgMap["content"].([]any) + if !ok { + continue + } + for _, block := range content { + blockMap, ok := block.(map[string]any) + if !ok { + continue + } + blockType, _ := blockMap["type"].(string) + if blockType == "thinking" { + return true + } + if blockType == "" { + if _, hasThinking := blockMap["thinking"]; hasThinking { + return true + } + } + } + } + return false + } + + tests := []struct { + name string + input string + shouldFilter bool + expectError bool + }{ + { + name: "filters thinking blocks", + input: `{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Hello"},{"type":"thinking","thinking":"internal","signature":"invalid"},{"type":"text","text":"World"}]}]}`, + shouldFilter: true, + }, + { + name: "handles no thinking blocks", + input: `{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Hello"}]}]}`, + shouldFilter: false, + }, + { + name: "handles invalid JSON gracefully", + input: `{invalid json`, + shouldFilter: false, + expectError: true, + }, + { + name: "handles multiple messages with thinking blocks", + input: `{"messages":[{"role":"user","content":[{"type":"text","text":"A"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"think"},{"type":"text","text":"B"}]}]}`, + shouldFilter: true, + }, + { + name: "filters thinking blocks without type discriminator", + input: `{"messages":[{"role":"assistant","content":[{"thinking":{"text":"internal"}},{"type":"text","text":"B"}]}]}`, + shouldFilter: true, + }, + { + name: "does not filter tool_use input fields named thinking", + input: `{"messages":[{"role":"user","content":[{"type":"tool_use","id":"t1","name":"foo","input":{"thinking":"keepme","x":1}},{"type":"text","text":"Hello"}]}]}`, + shouldFilter: false, + }, + { + name: "handles empty messages array", + input: `{"messages":[]}`, + shouldFilter: false, + }, + { + name: "handles missing messages field", + input: `{"model":"claude-3"}`, + shouldFilter: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FilterThinkingBlocks([]byte(tt.input)) + + if tt.expectError { + // For invalid JSON, should return original + require.Equal(t, tt.input, string(result)) + return + } + + if tt.shouldFilter { + require.False(t, containsThinkingBlock(result)) + } else { + // Ensure we don't rewrite JSON when no filtering is needed. + require.Equal(t, tt.input, string(result)) + } + + // Verify valid JSON returned (unless input was invalid) + var parsed map[string]any + err := json.Unmarshal(result, &parsed) + require.NoError(t, err) + }) + } +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index f735d2d8..d78507b6 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1136,6 +1136,10 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } } + // Filter thinking blocks from request body (prevents 400 errors from missing/invalid signatures). + // We apply this for the main /v1/messages path as well as count_tokens. + body = FilterThinkingBlocks(body) + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, err @@ -1862,6 +1866,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } } + // Filter thinking blocks from request body (prevents 400 errors from invalid signatures) + body = FilterThinkingBlocks(body) + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, err diff --git a/deploy/docker-compose.override.yml b/deploy/docker-compose.override.yml deleted file mode 100644 index d877ff50..00000000 --- a/deploy/docker-compose.override.yml +++ /dev/null @@ -1,21 +0,0 @@ -# ============================================================================= -# Docker Compose Override for Local Development -# ============================================================================= -# This file automatically extends docker-compose-test.yml -# Usage: docker-compose -f docker-compose-test.yml up -d -# ============================================================================= - -services: - # =========================================================================== - # PostgreSQL - 暴露端口用于本地开发 - # =========================================================================== - postgres: - ports: - - "127.0.0.1:5432:5432" - - # =========================================================================== - # Redis - 暴露端口用于本地开发 - # =========================================================================== - redis: - ports: - - "127.0.0.1:6379:6379" diff --git a/deploy/docker-compose.override.yml.example b/deploy/docker-compose.override.yml.example new file mode 100644 index 00000000..297724f5 --- /dev/null +++ b/deploy/docker-compose.override.yml.example @@ -0,0 +1,137 @@ +# ============================================================================= +# Docker Compose Override Configuration Example +# ============================================================================= +# This file provides examples for customizing the Docker Compose setup. +# Copy this file to docker-compose.override.yml and modify as needed. +# +# Usage: +# cp docker-compose.override.yml.example docker-compose.override.yml +# # Edit docker-compose.override.yml with your settings +# docker-compose up -d +# +# IMPORTANT: docker-compose.override.yml is gitignored and will not be committed. +# ============================================================================= + +# ============================================================================= +# Scenario 1: Use External Database and Redis (Recommended for Production) +# ============================================================================= +# Use this when you have PostgreSQL and Redis running on the host machine +# or on separate servers. +# +# Prerequisites: +# - PostgreSQL running on host (accessible via host.docker.internal) +# - Redis running on host (accessible via host.docker.internal) +# - Update DATABASE_PORT and REDIS_PORT in .env file if using non-standard ports +# +# Security Notes: +# - Ensure PostgreSQL pg_hba.conf allows connections from Docker network +# - Use strong passwords for database and Redis +# - Consider using SSL/TLS for database connections in production +# ============================================================================= + +services: + sub2api: + # Remove dependencies on containerized postgres/redis + depends_on: [] + + # Enable access to host machine services + extra_hosts: + - "host.docker.internal:host-gateway" + + # Override database and Redis connection settings + environment: + # PostgreSQL Configuration + DATABASE_HOST: host.docker.internal + DATABASE_PORT: "5678" # Change to your PostgreSQL port + # DATABASE_USER: postgres # Uncomment to override + # DATABASE_PASSWORD: your_password # Uncomment to override + # DATABASE_DBNAME: sub2api # Uncomment to override + + # Redis Configuration + REDIS_HOST: host.docker.internal + REDIS_PORT: "6379" # Change to your Redis port + # REDIS_PASSWORD: your_redis_password # Uncomment if Redis requires auth + # REDIS_DB: 0 # Uncomment to override + + # Disable containerized PostgreSQL + postgres: + deploy: + replicas: 0 + scale: 0 + + # Disable containerized Redis + redis: + deploy: + replicas: 0 + scale: 0 + +# ============================================================================= +# Scenario 2: Development with Local Services (Alternative) +# ============================================================================= +# Uncomment this section if you want to use the containerized postgres/redis +# but expose their ports for local development tools. +# +# Usage: Comment out Scenario 1 above and uncomment this section. +# ============================================================================= + +# services: +# sub2api: +# # Keep default dependencies +# pass +# +# postgres: +# ports: +# - "127.0.0.1:5432:5432" # Expose PostgreSQL on localhost +# +# redis: +# ports: +# - "127.0.0.1:6379:6379" # Expose Redis on localhost + +# ============================================================================= +# Scenario 3: Custom Network Configuration +# ============================================================================= +# Uncomment if you need to connect to an existing Docker network +# ============================================================================= + +# networks: +# default: +# external: true +# name: your-existing-network + +# ============================================================================= +# Scenario 4: Resource Limits (Production) +# ============================================================================= +# Uncomment to set resource limits for the sub2api container +# ============================================================================= + +# services: +# sub2api: +# deploy: +# resources: +# limits: +# cpus: '2.0' +# memory: 2G +# reservations: +# cpus: '1.0' +# memory: 1G + +# ============================================================================= +# Scenario 5: Custom Volumes +# ============================================================================= +# Uncomment to mount additional volumes (e.g., for logs, backups) +# ============================================================================= + +# services: +# sub2api: +# volumes: +# - ./logs:/app/logs +# - ./backups:/app/backups + +# ============================================================================= +# Additional Notes +# ============================================================================= +# - This file overrides settings in docker-compose.yml +# - Environment variables in .env file take precedence +# - For more information, see: https://docs.docker.com/compose/extends/ +# - Check the main README.md for detailed configuration instructions +# =============================================================================