Fix/multiple issues (#24)
* fix(gemini): 修复 google_one OAuth 配置和 scopes 问题 - 修复 google_one 类型在 ExchangeCode 和 RefreshToken 中使用内置客户端 - 添加 DefaultGoogleOneScopes,包含 generative-language 和 drive.readonly 权限 - 在 EffectiveOAuthConfig 中为 google_one 类型使用专门的 scopes - 将 docker-compose.override.yml 重命名为 .example 并添加到 .gitignore - 完善 docker-compose.override.yml.example 示例文档 解决问题: 1. google_one OAuth 授权后 API 调用返回 403 权限不足 2. 缺少访问 Gemini API 所需的 generative-language scope 3. 缺少获取 Drive 存储配额所需的 drive.readonly scope * fix(antigravity): 完全跳过 Claude 模型的所有 thinking 块 问题分析: - 当前代码尝试保留有 signature 的 thinking 块 - 但 Vertex AI 的 signature 是完整性令牌,无法在本地验证 - 导致 400 错误:Invalid signature in thinking block 根本原因: 1. thinking 功能已对非 Gemini 模型禁用 (isThinkingEnabled=false) 2. Vertex AI 要求原样重放 (thinking, signature) 对或完全不发送 3. 本地无法复制 Vertex 的加密验证逻辑 修复方案: - 对 Claude 模型完全跳过所有 thinking 块(无论是否有 signature) - 保持 Gemini 模型使用 dummy signature 的行为不变 - 更新测试用例以反映新的预期行为 影响: - 消除 thinking 相关的 400 错误 - 与现有的 thinking 禁用策略保持一致 - 不影响 Gemini 模型的 thinking 功能 测试: - ✅ TestBuildParts_ThinkingBlockWithoutSignature 全部通过 - ✅ TestBuildTools_CustomTypeTools 全部通过 参考:Codex review 建议 * fix(gateway): 修复 count_tokens 端点 400 错误 问题分析: - count_tokens 请求包含 thinking 块时返回 400 错误 - 原因:thinking 块未被过滤,直接转发到上游 API - 上游 API 拒绝无效的 thinking signature 根本原因: 1. /v1/messages 请求通过 TransformClaudeToGemini 过滤 thinking 块 2. count_tokens 请求绕过转换,直接转发原始请求体 3. 导致包含无效 signature 的 thinking 块被发送到上游 修复方案: - 创建 FilterThinkingBlocks 工具函数 - 在 buildCountTokensRequest 中应用过滤(1 行修改) - 与 /v1/messages 行为保持一致 实现细节: - FilterThinkingBlocks: 解析 JSON,过滤 thinking 块,重新序列化 - 失败安全:解析/序列化失败时返回原始请求体 - 性能优化:仅在发现 thinking 块时重新序列化 测试: - ✅ 6 个单元测试全部通过 - ✅ 覆盖正常过滤、无 thinking 块、无效 JSON 等场景 - ✅ 现有测试不受影响 影响: - 消除 count_tokens 的 400 错误 - 不影响 Antigravity 账号(仍返回模拟响应) - 适用于所有账号类型(OAuth、API Key) 文件修改: - backend/internal/service/gateway_request.go: +62 行(新函数) - backend/internal/service/gateway_service.go: +2 行(应用过滤) - backend/internal/service/gateway_request_test.go: +62 行(测试) * fix(gateway): 增强 thinking 块过滤逻辑 基于 Codex 分析和建议的改进: 问题分析: - 新错误:signature: Field required(signature 字段缺失) - 旧错误:Invalid signature(signature 存在但无效) - 两者都说明 thinking 块在请求中是危险的 Codex 建议: - 保持 Option A:完全跳过所有 thinking 块 - 原因:thinking 块应该是只输出的,除非有服务端来源证明 - 在无状态代理中,无法安全区分上游来源 vs 客户端注入 改进内容: 1. 增强 FilterThinkingBlocks 函数 - 过滤显式的 thinking 块:{"type":"thinking", ...} - 过滤无 type 的 thinking 对象:{"thinking": {...}} - 保留 tool_use 等其他类型块中的 thinking 字段 - 修复:只在实际过滤时更新 content 数组 2. 扩展过滤范围 - 将 FilterThinkingBlocks 应用到 /v1/messages 主路径 - 之前只应用于 count_tokens,现在两个端点都过滤 - 防止所有端点的 thinking 相关 400 错误 3. 改进测试 - 新增:过滤无 type discriminator 的 thinking 块 - 新增:不过滤 tool_use 中的 thinking 字段 - 使用 containsThinkingBlock 辅助函数验证 测试: - ✅ 8 个测试用例全部通过 - ✅ 覆盖各种 thinking 块格式 - ✅ 确保不误伤其他类型的块 影响: - 消除 signature required 和 invalid signature 错误 - 统一 /v1/messages 和 count_tokens 的行为 - 更健壮的 thinking 块检测逻辑 参考:Codex review 和代码改进 * refactor: 根据 Codex 审查建议进行代码优化 基于 Codex 代码审查的 P1 和 P2 改进: P1 改进(重要问题): 1. 优化日志输出 - 移除 thinking 块跳过时的 log.Printf - 避免高频请求下的日志噪音 - 添加注释说明可通过指标监控 2. 清理遗留代码 - 删除未使用的 isValidThoughtSignature 函数(27行) - 该函数在改为完全跳过 thinking 块后不再需要 P2 改进(性能优化): 3. 添加快速路径检查 - 在 FilterThinkingBlocks 中添加 bytes.Contains 预检查 - 如果请求体不包含 "thinking" 字符串,直接返回 - 避免不必要的 JSON 解析,提升性能 技术细节: - request_transformer.go: -27行(删除函数),+1行(优化注释) - gateway_request.go: +5行(快速路径 + bytes 导入) 测试: - ✅ TestBuildParts_ThinkingBlockWithoutSignature 全部通过 - ✅ TestFilterThinkingBlocks 全部通过(8个测试用例) 影响: - 减少日志噪音 - 提升性能(快速路径) - 代码更简洁(删除未使用代码) 参考:Codex 代码审查建议 * fix: 修复 golangci-lint 检查问题 - 格式化 gateway_request_test.go - 使用 switch 语句替代 if-else 链(staticcheck QF1003) * fix(antigravity): 修复 thinking signature 处理并实现 Auto 模式降级 问题分析: 1. 原先代码错误地禁用了 Claude via Vertex 的 thinkingConfig 2. 历史 thinking 块的 signature 被完全跳过,导致验证失败 3. 跨模型混用时 dummy signature 会导致 400 错误 修复内容: **request_transformer.go**: - 删除第 38-43 行的错误逻辑(禁用 thinkingConfig) - 引入 thoughtSignatureMode(Preserve/Dummy)策略 - Claude 模式:透传真实 signature,过滤空/dummy - Gemini 模式:使用 dummy signature - 支持 signature-only thinking 块 - tool_use 的 signature 也透传 **antigravity_gateway_service.go**: - 新增 isSignatureRelatedError() 检测 signature 相关错误 - 新增 stripThinkingFromClaudeRequest() 移除 thinking 块 - 实现 Auto 模式:检测 400 + signature 关键词时自动降级重试 - 重试时完全移除 thinking 配置和消息中的 thinking 块 - 最多重试一次,避免循环 **测试**: - 更新并新增测试覆盖 Claude preserve/Gemini dummy 模式 - 新增 tool_use signature 处理测试 - 所有测试通过(6/6) 影响: - ✅ Claude via Vertex 可以正常使用 thinking 功能 - ✅ 历史 signature 正确透传,避免验证失败 - ✅ 跨模型混用时自动过滤无效 signature - ✅ 错误驱动降级,自动修复 signature 问题 - ✅ 不影响纯 Claude API 和其他渠道 参考:Codex 深度分析和实现建议 * fix(lint): 修复 gofmt 格式问题 * fix(antigravity): 修复 stripThinkingFromClaudeRequest 遗漏 untyped thinking blocks 问题: - Codex 审查指出 stripThinkingFromClaudeRequest 只移除了 type="thinking" 的块 - 没有处理没有 type 字段的 thinking 对象(如 {"thinking": "...", "signature": "..."}) - 导致重试时仍包含无效 thinking 块,上游 400 错误持续 修复: - 添加检查:跳过没有 type 但有 thinking 字段的块 - 现在会移除两种格式: 1. {"type": "thinking", "thinking": "...", "signature": "..."} 2. {"thinking": "...", "signature": "..."}(untyped) 测试:所有测试通过 参考:Codex P1 审查意见
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -118,3 +118,4 @@ docs/
|
|||||||
code-reviews/
|
code-reviews/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
backend/cmd/server/server
|
backend/cmd/server/server
|
||||||
|
deploy/docker-compose.override.yml
|
||||||
|
|||||||
@@ -20,12 +20,18 @@ func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel st
|
|||||||
|
|
||||||
// 检测是否启用 thinking
|
// 检测是否启用 thinking
|
||||||
requestedThinkingEnabled := claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled"
|
requestedThinkingEnabled := claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled"
|
||||||
// 为避免 Claude 模型的 thought signature/消息块约束导致 400(上游要求 thinking 块开头等),
|
// antigravity(v1internal) 下,Gemini 与 Claude 的 “thinking” 都可能涉及 thoughtSignature 链路:
|
||||||
// 非 Gemini 模型默认不启用 thinking(除非未来支持完整签名链路)。
|
// - Gemini:支持 dummy signature 跳过校验
|
||||||
isThinkingEnabled := requestedThinkingEnabled && allowDummyThought
|
// - Claude:需要透传上游签名(否则容易 400)
|
||||||
|
isThinkingEnabled := requestedThinkingEnabled
|
||||||
|
|
||||||
|
thoughtSignatureMode := thoughtSignatureModePreserve
|
||||||
|
if allowDummyThought {
|
||||||
|
thoughtSignatureMode = thoughtSignatureModeDummy
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 构建 contents
|
// 1. 构建 contents
|
||||||
contents, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, allowDummyThought)
|
contents, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, thoughtSignatureMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build contents: %w", err)
|
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)
|
systemInstruction := buildSystemInstruction(claudeReq.System, claudeReq.Model)
|
||||||
|
|
||||||
// 3. 构建 generationConfig
|
// 3. 构建 generationConfig
|
||||||
reqForGen := claudeReq
|
generationConfig := buildGenerationConfig(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)
|
|
||||||
|
|
||||||
// 4. 构建 tools
|
// 4. 构建 tools
|
||||||
tools := buildTools(claudeReq.Tools)
|
tools := buildTools(claudeReq.Tools)
|
||||||
@@ -131,7 +129,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string) *GeminiCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildContents 构建 contents
|
// 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
|
var contents []GeminiContent
|
||||||
|
|
||||||
for i, msg := range messages {
|
for i, msg := range messages {
|
||||||
@@ -140,11 +138,13 @@ func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isT
|
|||||||
role = "model"
|
role = "model"
|
||||||
}
|
}
|
||||||
|
|
||||||
parts, err := buildParts(msg.Content, toolIDToName, allowDummyThought)
|
parts, err := buildParts(msg.Content, toolIDToName, thoughtSignatureMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build parts for message %d: %w", i, err)
|
return nil, fmt.Errorf("build parts for message %d: %w", i, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowDummyThought := thoughtSignatureMode == thoughtSignatureModeDummy
|
||||||
|
|
||||||
// 只有 Gemini 模型支持 dummy thinking block workaround
|
// 只有 Gemini 模型支持 dummy thinking block workaround
|
||||||
// 只对最后一条 assistant 消息添加(Pre-fill 场景)
|
// 只对最后一条 assistant 消息添加(Pre-fill 场景)
|
||||||
// 历史 assistant 消息不能添加没有 signature 的 dummy thinking block
|
// 历史 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
|
// 参考: https://ai.google.dev/gemini-api/docs/thought-signatures
|
||||||
const dummyThoughtSignature = "skip_thought_signature_validator"
|
const dummyThoughtSignature = "skip_thought_signature_validator"
|
||||||
|
|
||||||
// isValidThoughtSignature 验证 thought signature 是否有效
|
// buildParts 构建消息的 parts
|
||||||
// Claude API 要求 signature 必须是 base64 编码的字符串,长度至少 32 字节
|
type thoughtSignatureMode int
|
||||||
func isValidThoughtSignature(signature string) bool {
|
|
||||||
// 空字符串无效
|
|
||||||
if signature == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// signature 应该是 base64 编码,长度至少 40 个字符(约 30 字节)
|
const (
|
||||||
// 参考 Claude API 文档和实际观察到的有效 signature
|
thoughtSignatureModePreserve thoughtSignatureMode = iota
|
||||||
if len(signature) < 40 {
|
thoughtSignatureModeDummy
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildParts 构建消息的 parts
|
// buildParts 构建消息的 parts
|
||||||
// allowDummyThought: 只有 Gemini 模型支持 dummy thought signature
|
// thoughtSignatureMode:
|
||||||
func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDummyThought bool) ([]GeminiPart, error) {
|
// - 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
|
var parts []GeminiPart
|
||||||
|
|
||||||
// 尝试解析为字符串
|
// 尝试解析为字符串
|
||||||
@@ -239,7 +221,9 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "thinking":
|
case "thinking":
|
||||||
if allowDummyThought {
|
signature := strings.TrimSpace(block.Signature)
|
||||||
|
|
||||||
|
if thoughtSignatureMode == thoughtSignatureModeDummy {
|
||||||
// Gemini 模型可以使用 dummy signature
|
// Gemini 模型可以使用 dummy signature
|
||||||
parts = append(parts, GeminiPart{
|
parts = append(parts, GeminiPart{
|
||||||
Text: block.Thinking,
|
Text: block.Thinking,
|
||||||
@@ -249,20 +233,27 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude 模型:仅在提供有效 signature 时保留 thinking block;否则跳过以避免上游校验失败。
|
// Claude via Vertex:
|
||||||
signature := strings.TrimSpace(block.Signature)
|
// - signature 是上游返回的完整性令牌;本地不需要/无法验证,只能透传
|
||||||
|
// - 缺失/无效 signature(例如来自 Gemini 的 dummy signature)会导致上游 400
|
||||||
if signature == "" || signature == dummyThoughtSignature {
|
if signature == "" || signature == dummyThoughtSignature {
|
||||||
log.Printf("[Warning] Skipping thinking block for Claude model (missing or dummy signature)")
|
|
||||||
continue
|
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{
|
parts = append(parts, GeminiPart{
|
||||||
Text: block.Thinking,
|
Text: block.Thinking,
|
||||||
Thought: true,
|
Thought: true,
|
||||||
ThoughtSignature: signature,
|
ThoughtSignature: signature,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
case "image":
|
case "image":
|
||||||
if block.Source != nil && block.Source.Type == "base64" {
|
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,
|
ID: block.ID,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// 只有 Gemini 模型使用 dummy signature
|
switch thoughtSignatureMode {
|
||||||
// Claude 模型不设置 signature(避免验证问题)
|
case thoughtSignatureModeDummy:
|
||||||
if allowDummyThought {
|
|
||||||
part.ThoughtSignature = dummyThoughtSignature
|
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)
|
parts = append(parts, part)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
content string
|
content string
|
||||||
allowDummyThought bool
|
thoughtMode thoughtSignatureMode
|
||||||
expectedParts int
|
expectedParts int
|
||||||
description string
|
description string
|
||||||
}{
|
}{
|
||||||
@@ -21,20 +21,20 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) {
|
|||||||
{"type": "thinking", "thinking": "Let me think...", "signature": ""},
|
{"type": "thinking", "thinking": "Let me think...", "signature": ""},
|
||||||
{"type": "text", "text": "World"}
|
{"type": "text", "text": "World"}
|
||||||
]`,
|
]`,
|
||||||
allowDummyThought: false,
|
thoughtMode: thoughtSignatureModePreserve,
|
||||||
expectedParts: 2, // 只有两个text block
|
expectedParts: 2, // 只有两个text block
|
||||||
description: "Claude模型应该跳过无signature的thinking block",
|
description: "Claude模型应该跳过无signature的thinking block",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Claude model - keep thinking block with signature",
|
name: "Claude model - preserve thinking block with signature",
|
||||||
content: `[
|
content: `[
|
||||||
{"type": "text", "text": "Hello"},
|
{"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"}
|
{"type": "text", "text": "World"}
|
||||||
]`,
|
]`,
|
||||||
allowDummyThought: false,
|
thoughtMode: thoughtSignatureModePreserve,
|
||||||
expectedParts: 3, // 三个block都保留
|
expectedParts: 3,
|
||||||
description: "Claude模型应该保留有signature的thinking block",
|
description: "Claude模型应透传带 signature 的 thinking block(用于 Vertex 签名链路)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Gemini model - use dummy signature",
|
name: "Gemini model - use dummy signature",
|
||||||
@@ -43,16 +43,27 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) {
|
|||||||
{"type": "thinking", "thinking": "Let me think...", "signature": ""},
|
{"type": "thinking", "thinking": "Let me think...", "signature": ""},
|
||||||
{"type": "text", "text": "World"}
|
{"type": "text", "text": "World"}
|
||||||
]`,
|
]`,
|
||||||
allowDummyThought: true,
|
thoughtMode: thoughtSignatureModeDummy,
|
||||||
expectedParts: 3, // 三个block都保留,thinking使用dummy signature
|
expectedParts: 3, // 三个block都保留,thinking使用dummy signature
|
||||||
description: "Gemini模型应该为无signature的thinking block使用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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
toolIDToName := make(map[string]string)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("buildParts() error = %v", err)
|
t.Fatalf("buildParts() error = %v", err)
|
||||||
@@ -61,10 +72,71 @@ func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) {
|
|||||||
if len(parts) != tt.expectedParts {
|
if len(parts) != tt.expectedParts {
|
||||||
t.Errorf("%s: got %d parts, want %d parts", tt.description, 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类型工具转换
|
// TestBuildTools_CustomTypeTools 测试custom类型工具转换
|
||||||
func TestBuildTools_CustomTypeTools(t *testing.T) {
|
func TestBuildTools_CustomTypeTools(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ const (
|
|||||||
// https://www.googleapis.com/auth/generative-language.retriever (often with cloud-platform).
|
// 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"
|
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 is the redirect URI used by Gemini CLI for Code Assist OAuth.
|
||||||
GeminiCLIRedirectURI = "https://codeassist.google.com/authcode"
|
GeminiCLIRedirectURI = "https://codeassist.google.com/authcode"
|
||||||
|
|
||||||
|
|||||||
@@ -172,14 +172,19 @@ func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error
|
|||||||
|
|
||||||
if effective.Scopes == "" {
|
if effective.Scopes == "" {
|
||||||
// Use different default scopes based on OAuth type
|
// 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).
|
// Built-in client can't request some AI Studio scopes (notably generative-language).
|
||||||
if isBuiltinClient {
|
if isBuiltinClient {
|
||||||
effective.Scopes = DefaultCodeAssistScopes
|
effective.Scopes = DefaultCodeAssistScopes
|
||||||
} else {
|
} else {
|
||||||
effective.Scopes = DefaultAIStudioScopes
|
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
|
// Default to Code Assist scopes
|
||||||
effective.Scopes = DefaultCodeAssistScopes
|
effective.Scopes = DefaultCodeAssistScopes
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,14 @@ func (c *geminiOAuthClient) ExchangeCode(ctx context.Context, oauthType, code, c
|
|||||||
|
|
||||||
// Use different OAuth clients based on oauthType:
|
// Use different OAuth clients based on oauthType:
|
||||||
// - code_assist: always use built-in Gemini CLI OAuth client (public)
|
// - 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
|
// - ai_studio: requires a user-provided OAuth client
|
||||||
oauthCfgInput := geminicli.OAuthConfig{
|
oauthCfgInput := geminicli.OAuthConfig{
|
||||||
ClientID: c.cfg.Gemini.OAuth.ClientID,
|
ClientID: c.cfg.Gemini.OAuth.ClientID,
|
||||||
ClientSecret: c.cfg.Gemini.OAuth.ClientSecret,
|
ClientSecret: c.cfg.Gemini.OAuth.ClientSecret,
|
||||||
Scopes: c.cfg.Gemini.OAuth.Scopes,
|
Scopes: c.cfg.Gemini.OAuth.Scopes,
|
||||||
}
|
}
|
||||||
if oauthType == "code_assist" {
|
if oauthType == "code_assist" || oauthType == "google_one" {
|
||||||
oauthCfgInput.ClientID = ""
|
oauthCfgInput.ClientID = ""
|
||||||
oauthCfgInput.ClientSecret = ""
|
oauthCfgInput.ClientSecret = ""
|
||||||
}
|
}
|
||||||
@@ -77,7 +78,7 @@ func (c *geminiOAuthClient) RefreshToken(ctx context.Context, oauthType, refresh
|
|||||||
ClientSecret: c.cfg.Gemini.OAuth.ClientSecret,
|
ClientSecret: c.cfg.Gemini.OAuth.ClientSecret,
|
||||||
Scopes: c.cfg.Gemini.OAuth.Scopes,
|
Scopes: c.cfg.Gemini.OAuth.Scopes,
|
||||||
}
|
}
|
||||||
if oauthType == "code_assist" {
|
if oauthType == "code_assist" || oauthType == "google_one" {
|
||||||
oauthCfgInput.ClientID = ""
|
oauthCfgInput.ClientID = ""
|
||||||
oauthCfgInput.ClientSecret = ""
|
oauthCfgInput.ClientSecret = ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,6 +307,74 @@ func (s *AntigravityGatewayService) unwrapV1InternalResponse(body []byte) ([]byt
|
|||||||
return body, nil
|
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 转换)
|
// Forward 转发 Claude 协议请求(Claude → Gemini 转换)
|
||||||
func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
|
func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -414,12 +482,71 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||||
s.handleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
s.handleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不是 signature 错误,或者已经没有 thinking 块,直接返回错误
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
if s.shouldFailoverUpstreamError(resp.StatusCode) {
|
if s.shouldFailoverUpstreamError(resp.StatusCode) {
|
||||||
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, s.writeMappedClaudeError(c, resp.StatusCode, respBody)
|
return nil, s.writeMappedClaudeError(c, resp.StatusCode, respBody)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
requestID := resp.Header.Get("x-request-id")
|
requestID := resp.Header.Get("x-request-id")
|
||||||
if requestID != "" {
|
if requestID != "" {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
@@ -70,3 +71,85 @@ func ParseGatewayRequest(body []byte) (*ParsedRequest, error) {
|
|||||||
|
|
||||||
return parsed, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -38,3 +39,115 @@ func TestParseGatewayRequest_InvalidStreamType(t *testing.T) {
|
|||||||
_, err := ParseGatewayRequest(body)
|
_, err := ParseGatewayRequest(body)
|
||||||
require.Error(t, err)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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))
|
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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"
|
|
||||||
137
deploy/docker-compose.override.yml.example
Normal file
137
deploy/docker-compose.override.yml.example
Normal file
@@ -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
|
||||||
|
# =============================================================================
|
||||||
Reference in New Issue
Block a user