Files
sub2api/backend/internal/service/gateway_request_test.go

1053 lines
32 KiB
Go
Raw Normal View History

//go:build unit
package service
import (
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 审查意见
2026-01-02 17:47:49 +08:00
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/stretchr/testify/require"
)
func TestParseGatewayRequest(t *testing.T) {
body := []byte(`{"model":"claude-3-7-sonnet","stream":true,"metadata":{"user_id":"session_123e4567-e89b-12d3-a456-426614174000"},"system":[{"type":"text","text":"hello","cache_control":{"type":"ephemeral"}}],"messages":[{"content":"hi"}]}`)
parsed, err := ParseGatewayRequest(body, "")
require.NoError(t, err)
require.Equal(t, "claude-3-7-sonnet", parsed.Model)
require.True(t, parsed.Stream)
require.Equal(t, "session_123e4567-e89b-12d3-a456-426614174000", parsed.MetadataUserID)
require.True(t, parsed.HasSystem)
require.NotNil(t, parsed.System)
require.Len(t, parsed.Messages, 1)
require.False(t, parsed.ThinkingEnabled)
}
func TestParseGatewayRequest_ThinkingEnabled(t *testing.T) {
body := []byte(`{"model":"claude-sonnet-4-5","thinking":{"type":"enabled"},"messages":[{"content":"hi"}]}`)
parsed, err := ParseGatewayRequest(body, "")
require.NoError(t, err)
require.Equal(t, "claude-sonnet-4-5", parsed.Model)
require.True(t, parsed.ThinkingEnabled)
}
func TestParseGatewayRequest_ThinkingAdaptiveEnabled(t *testing.T) {
body := []byte(`{"model":"claude-sonnet-4-5","thinking":{"type":"adaptive"},"messages":[{"content":"hi"}]}`)
parsed, err := ParseGatewayRequest(body, "")
require.NoError(t, err)
require.Equal(t, "claude-sonnet-4-5", parsed.Model)
require.True(t, parsed.ThinkingEnabled)
}
func TestParseGatewayRequest_MaxTokens(t *testing.T) {
body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1}`)
parsed, err := ParseGatewayRequest(body, "")
require.NoError(t, err)
require.Equal(t, 1, parsed.MaxTokens)
}
func TestParseGatewayRequest_MaxTokensNonIntegralIgnored(t *testing.T) {
body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1.5}`)
parsed, err := ParseGatewayRequest(body, "")
require.NoError(t, err)
require.Equal(t, 0, parsed.MaxTokens)
}
func TestParseGatewayRequest_SystemNull(t *testing.T) {
body := []byte(`{"model":"claude-3","system":null}`)
parsed, err := ParseGatewayRequest(body, "")
require.NoError(t, err)
// 显式传入 system:null 也应视为“字段已存在”,避免默认 system 被注入。
require.True(t, parsed.HasSystem)
require.Nil(t, parsed.System)
}
func TestParseGatewayRequest_InvalidModelType(t *testing.T) {
body := []byte(`{"model":123}`)
_, err := ParseGatewayRequest(body, "")
require.Error(t, err)
}
func TestParseGatewayRequest_InvalidStreamType(t *testing.T) {
body := []byte(`{"stream":"true"}`)
_, err := ParseGatewayRequest(body, "")
require.Error(t, err)
}
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 审查意见
2026-01-02 17:47:49 +08:00
// ============ Gemini 原生格式解析测试 ============
func TestParseGatewayRequest_GeminiContents(t *testing.T) {
body := []byte(`{
"contents": [
{"role": "user", "parts": [{"text": "Hello"}]},
{"role": "model", "parts": [{"text": "Hi there"}]},
{"role": "user", "parts": [{"text": "How are you?"}]}
]
}`)
parsed, err := ParseGatewayRequest(body, domain.PlatformGemini)
require.NoError(t, err)
require.Len(t, parsed.Messages, 3, "should parse contents as Messages")
require.False(t, parsed.HasSystem, "Gemini format should not set HasSystem")
require.Nil(t, parsed.System, "no systemInstruction means nil System")
}
func TestParseGatewayRequest_GeminiSystemInstruction(t *testing.T) {
body := []byte(`{
"systemInstruction": {
"parts": [{"text": "You are a helpful assistant."}]
},
"contents": [
{"role": "user", "parts": [{"text": "Hello"}]}
]
}`)
parsed, err := ParseGatewayRequest(body, domain.PlatformGemini)
require.NoError(t, err)
require.NotNil(t, parsed.System, "should parse systemInstruction.parts as System")
parts, ok := parsed.System.([]any)
require.True(t, ok)
require.Len(t, parts, 1)
partMap, ok := parts[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "You are a helpful assistant.", partMap["text"])
require.Len(t, parsed.Messages, 1)
}
func TestParseGatewayRequest_GeminiWithModel(t *testing.T) {
body := []byte(`{
"model": "gemini-2.5-pro",
"contents": [{"role": "user", "parts": [{"text": "test"}]}]
}`)
parsed, err := ParseGatewayRequest(body, domain.PlatformGemini)
require.NoError(t, err)
require.Equal(t, "gemini-2.5-pro", parsed.Model)
require.Len(t, parsed.Messages, 1)
}
func TestParseGatewayRequest_GeminiIgnoresAnthropicFields(t *testing.T) {
// Gemini 格式下 system/messages 字段应被忽略
body := []byte(`{
"system": "should be ignored",
"messages": [{"role": "user", "content": "ignored"}],
"contents": [{"role": "user", "parts": [{"text": "real content"}]}]
}`)
parsed, err := ParseGatewayRequest(body, domain.PlatformGemini)
require.NoError(t, err)
require.False(t, parsed.HasSystem, "Gemini protocol should not parse Anthropic system field")
require.Nil(t, parsed.System, "no systemInstruction = nil System")
require.Len(t, parsed.Messages, 1, "should use contents, not messages")
}
func TestParseGatewayRequest_GeminiEmptyContents(t *testing.T) {
body := []byte(`{"contents": []}`)
parsed, err := ParseGatewayRequest(body, domain.PlatformGemini)
require.NoError(t, err)
require.Empty(t, parsed.Messages)
}
func TestParseGatewayRequest_GeminiNoContents(t *testing.T) {
body := []byte(`{"model": "gemini-2.5-flash"}`)
parsed, err := ParseGatewayRequest(body, domain.PlatformGemini)
require.NoError(t, err)
require.Nil(t, parsed.Messages)
require.Equal(t, "gemini-2.5-flash", parsed.Model)
}
func TestParseGatewayRequest_AnthropicIgnoresGeminiFields(t *testing.T) {
// Anthropic 格式下 contents/systemInstruction 字段应被忽略
body := []byte(`{
"system": "real system",
"messages": [{"role": "user", "content": "real content"}],
"contents": [{"role": "user", "parts": [{"text": "ignored"}]}],
"systemInstruction": {"parts": [{"text": "ignored"}]}
}`)
parsed, err := ParseGatewayRequest(body, domain.PlatformAnthropic)
require.NoError(t, err)
require.True(t, parsed.HasSystem)
require.Equal(t, "real system", parsed.System)
require.Len(t, parsed.Messages, 1)
msg, ok := parsed.Messages[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "real content", msg["content"])
}
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 审查意见
2026-01-02 17:47:49 +08:00
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: "does not filter signed thinking blocks when thinking adaptive",
input: `{"thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"ok","signature":"sig_real_123"},{"type":"text","text":"B"}]}]}`,
shouldFilter: false,
},
{
name: "filters unsigned thinking blocks when thinking adaptive",
input: `{"thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"internal","signature":""},{"type":"text","text":"B"}]}]}`,
shouldFilter: true,
},
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 审查意见
2026-01-02 17:47:49 +08:00
{
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)
})
}
}
func TestFilterThinkingBlocksForRetry_DisablesThinkingAndPreservesAsText(t *testing.T) {
input := []byte(`{
"model":"claude-3-5-sonnet-20241022",
"thinking":{"type":"enabled","budget_tokens":1024},
"messages":[
{"role":"user","content":[{"type":"text","text":"Hi"}]},
{"role":"assistant","content":[
{"type":"thinking","thinking":"Let me think...","signature":"bad_sig"},
{"type":"text","text":"Answer"}
]}
]
}`)
out := FilterThinkingBlocksForRetry(input)
var req map[string]any
require.NoError(t, json.Unmarshal(out, &req))
_, hasThinking := req["thinking"]
require.False(t, hasThinking)
msgs, ok := req["messages"].([]any)
require.True(t, ok)
require.Len(t, msgs, 2)
assistant, ok := msgs[1].(map[string]any)
require.True(t, ok)
content, ok := assistant["content"].([]any)
require.True(t, ok)
require.Len(t, content, 2)
first, ok := content[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "text", first["type"])
require.Equal(t, "Let me think...", first["text"])
}
func TestFilterThinkingBlocksForRetry_DisablesThinkingEvenWithoutThinkingBlocks(t *testing.T) {
input := []byte(`{
"model":"claude-3-5-sonnet-20241022",
"thinking":{"type":"enabled","budget_tokens":1024},
"messages":[
{"role":"user","content":[{"type":"text","text":"Hi"}]},
{"role":"assistant","content":[{"type":"text","text":"Prefill"}]}
]
}`)
out := FilterThinkingBlocksForRetry(input)
var req map[string]any
require.NoError(t, json.Unmarshal(out, &req))
_, hasThinking := req["thinking"]
require.False(t, hasThinking)
}
func TestFilterThinkingBlocksForRetry_RemovesRedactedThinkingAndKeepsValidContent(t *testing.T) {
input := []byte(`{
"thinking":{"type":"enabled","budget_tokens":1024},
"messages":[
{"role":"assistant","content":[
{"type":"redacted_thinking","data":"..."},
{"type":"text","text":"Visible"}
]}
]
}`)
out := FilterThinkingBlocksForRetry(input)
var req map[string]any
require.NoError(t, json.Unmarshal(out, &req))
_, hasThinking := req["thinking"]
require.False(t, hasThinking)
msgs, ok := req["messages"].([]any)
require.True(t, ok)
msg0, ok := msgs[0].(map[string]any)
require.True(t, ok)
content, ok := msg0["content"].([]any)
require.True(t, ok)
require.Len(t, content, 1)
content0, ok := content[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "text", content0["type"])
require.Equal(t, "Visible", content0["text"])
}
func TestFilterThinkingBlocksForRetry_EmptyContentGetsPlaceholder(t *testing.T) {
input := []byte(`{
"thinking":{"type":"enabled"},
"messages":[
{"role":"assistant","content":[{"type":"redacted_thinking","data":"..."}]}
]
}`)
out := FilterThinkingBlocksForRetry(input)
var req map[string]any
require.NoError(t, json.Unmarshal(out, &req))
msgs, ok := req["messages"].([]any)
require.True(t, ok)
msg0, ok := msgs[0].(map[string]any)
require.True(t, ok)
content, ok := msg0["content"].([]any)
require.True(t, ok)
require.Len(t, content, 1)
content0, ok := content[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "text", content0["type"])
require.NotEmpty(t, content0["text"])
}
func TestFilterSignatureSensitiveBlocksForRetry_DowngradesTools(t *testing.T) {
input := []byte(`{
"thinking":{"type":"enabled","budget_tokens":1024},
"messages":[
{"role":"assistant","content":[
{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}},
{"type":"tool_result","tool_use_id":"t1","content":"ok","is_error":false}
]}
]
}`)
out := FilterSignatureSensitiveBlocksForRetry(input)
var req map[string]any
require.NoError(t, json.Unmarshal(out, &req))
_, hasThinking := req["thinking"]
require.False(t, hasThinking)
msgs, ok := req["messages"].([]any)
require.True(t, ok)
msg0, ok := msgs[0].(map[string]any)
require.True(t, ok)
content, ok := msg0["content"].([]any)
require.True(t, ok)
require.Len(t, content, 2)
content0, ok := content[0].(map[string]any)
require.True(t, ok)
content1, ok := content[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "text", content0["type"])
require.Equal(t, "text", content1["type"])
require.Contains(t, content0["text"], "tool_use")
require.Contains(t, content1["text"], "tool_result")
}
2026-03-08 21:08:09 +08:00
// ============ Group 6b: context_management.edits 清理测试 ============
// removeThinkingDependentContextStrategies — 边界用例
func TestRemoveThinkingDependentContextStrategies_NoContextManagement(t *testing.T) {
input := []byte(`{"thinking":{"type":"enabled"},"messages":[]}`)
out := removeThinkingDependentContextStrategies(input)
require.Equal(t, input, out, "无 context_management 字段时应原样返回")
}
func TestRemoveThinkingDependentContextStrategies_EmptyEdits(t *testing.T) {
input := []byte(`{"context_management":{"edits":[]},"messages":[]}`)
out := removeThinkingDependentContextStrategies(input)
require.Equal(t, input, out, "edits 为空数组时应原样返回")
}
func TestRemoveThinkingDependentContextStrategies_NoClearThinkingEntry(t *testing.T) {
input := []byte(`{"context_management":{"edits":[{"type":"other_strategy"}]},"messages":[]}`)
out := removeThinkingDependentContextStrategies(input)
require.Equal(t, input, out, "edits 中无 clear_thinking_20251015 时应原样返回")
}
func TestRemoveThinkingDependentContextStrategies_RemovesSingleEntry(t *testing.T) {
input := []byte(`{"context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`)
out := removeThinkingDependentContextStrategies(input)
var req map[string]any
require.NoError(t, json.Unmarshal(out, &req))
cm, ok := req["context_management"].(map[string]any)
require.True(t, ok)
_, hasEdits := cm["edits"]
require.False(t, hasEdits, "所有 edits 均为 clear_thinking_20251015 时应删除 edits 键")
}
func TestRemoveThinkingDependentContextStrategies_MixedEntries(t *testing.T) {
input := []byte(`{"context_management":{"edits":[{"type":"clear_thinking_20251015"},{"type":"other_strategy","param":1}]},"messages":[]}`)
out := removeThinkingDependentContextStrategies(input)
var req map[string]any
require.NoError(t, json.Unmarshal(out, &req))
cm, ok := req["context_management"].(map[string]any)
require.True(t, ok)
edits, ok := cm["edits"].([]any)
require.True(t, ok)
require.Len(t, edits, 1, "仅移除 clear_thinking_20251015保留其他条目")
edit0, ok := edits[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "other_strategy", edit0["type"])
}
// FilterThinkingBlocksForRetry — 包含 context_management 的场景
func TestFilterThinkingBlocksForRetry_RemovesClearThinkingStrategy_FastPath(t *testing.T) {
// 快速路径messages 中无 thinking 块,仅有顶层 thinking 字段
// 这条路径曾因提前 return 跳过 removeThinkingDependentContextStrategies 而存在 bug
input := []byte(`{
"thinking":{"type":"enabled","budget_tokens":1024},
"context_management":{"edits":[{"type":"clear_thinking_20251015"}]},
"messages":[
{"role":"user","content":[{"type":"text","text":"Hello"}]}
]
}`)
out := FilterThinkingBlocksForRetry(input)
var req map[string]any
require.NoError(t, json.Unmarshal(out, &req))
_, hasThinking := req["thinking"]
require.False(t, hasThinking, "顶层 thinking 应被移除")
cm, ok := req["context_management"].(map[string]any)
require.True(t, ok)
_, hasEdits := cm["edits"]
require.False(t, hasEdits, "fast path 下 clear_thinking_20251015 应被移除edits 键应被删除")
}
func TestFilterThinkingBlocksForRetry_RemovesClearThinkingStrategy_WithThinkingBlocks(t *testing.T) {
// 完整路径messages 中有 thinking 块(非 fast path
input := []byte(`{
"thinking":{"type":"enabled","budget_tokens":1024},
"context_management":{"edits":[{"type":"clear_thinking_20251015"},{"type":"keep_this"}]},
"messages":[
{"role":"assistant","content":[
{"type":"thinking","thinking":"some thought","signature":"sig"},
{"type":"text","text":"Answer"}
]}
]
}`)
out := FilterThinkingBlocksForRetry(input)
var req map[string]any
require.NoError(t, json.Unmarshal(out, &req))
_, hasThinking := req["thinking"]
require.False(t, hasThinking, "顶层 thinking 应被移除")
cm, ok := req["context_management"].(map[string]any)
require.True(t, ok)
edits, ok := cm["edits"].([]any)
require.True(t, ok)
require.Len(t, edits, 1, "仅移除 clear_thinking_20251015保留 keep_this")
edit0, ok := edits[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "keep_this", edit0["type"])
}
func TestFilterThinkingBlocksForRetry_NoContextManagement_Unaffected(t *testing.T) {
// 无 context_management 时不应报错,且 thinking 正常被移除
input := []byte(`{
"thinking":{"type":"enabled"},
"messages":[{"role":"user","content":[{"type":"text","text":"Hi"}]}]
}`)
out := FilterThinkingBlocksForRetry(input)
var req map[string]any
require.NoError(t, json.Unmarshal(out, &req))
_, hasThinking := req["thinking"]
require.False(t, hasThinking)
_, hasCM := req["context_management"]
require.False(t, hasCM)
}
// FilterSignatureSensitiveBlocksForRetry — 包含 context_management 的场景
func TestFilterSignatureSensitiveBlocksForRetry_RemovesClearThinkingStrategy(t *testing.T) {
input := []byte(`{
"thinking":{"type":"enabled","budget_tokens":1024},
"context_management":{"edits":[{"type":"clear_thinking_20251015"}]},
"messages":[
{"role":"assistant","content":[
{"type":"thinking","thinking":"thought","signature":"sig"}
]}
]
}`)
out := FilterSignatureSensitiveBlocksForRetry(input)
var req map[string]any
require.NoError(t, json.Unmarshal(out, &req))
_, hasThinking := req["thinking"]
require.False(t, hasThinking, "顶层 thinking 应被移除")
cm, ok := req["context_management"].(map[string]any)
require.True(t, ok)
if rawEdits, hasEdits := cm["edits"]; hasEdits {
edits, ok := rawEdits.([]any)
2026-03-08 21:08:09 +08:00
require.True(t, ok)
for _, e := range edits {
em, ok := e.(map[string]any)
require.True(t, ok)
require.NotEqual(t, "clear_thinking_20251015", em["type"], "clear_thinking_20251015 应被移除")
}
2026-03-08 21:08:09 +08:00
}
}
func TestFilterSignatureSensitiveBlocksForRetry_PreservesNonThinkingStrategies(t *testing.T) {
input := []byte(`{
"thinking":{"type":"enabled"},
"context_management":{"edits":[{"type":"clear_thinking_20251015"},{"type":"other_edit"}]},
"messages":[
{"role":"assistant","content":[
{"type":"thinking","thinking":"t","signature":"s"}
]}
]
}`)
out := FilterSignatureSensitiveBlocksForRetry(input)
var req map[string]any
require.NoError(t, json.Unmarshal(out, &req))
cm, ok := req["context_management"].(map[string]any)
require.True(t, ok)
edits, ok := cm["edits"].([]any)
require.True(t, ok)
require.Len(t, edits, 1, "仅移除 clear_thinking_20251015保留 other_edit")
edit0, ok := edits[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "other_edit", edit0["type"])
}
func TestFilterSignatureSensitiveBlocksForRetry_NoThinkingField_ContextManagementUntouched(t *testing.T) {
// 没有顶层 thinking 字段时context_management 不应被修改
input := []byte(`{
"context_management":{"edits":[{"type":"clear_thinking_20251015"}]},
"messages":[
{"role":"assistant","content":[
{"type":"thinking","thinking":"t","signature":"s"}
]}
]
}`)
out := FilterSignatureSensitiveBlocksForRetry(input)
var req map[string]any
require.NoError(t, json.Unmarshal(out, &req))
cm, ok := req["context_management"].(map[string]any)
require.True(t, ok)
edits, ok := cm["edits"].([]any)
require.True(t, ok)
require.Len(t, edits, 1, "无顶层 thinking 时 context_management 不应被修改")
}
// ============ Group 7: ParseGatewayRequest 补充单元测试 ============
// Task 7.1 — 类型校验边界测试
func TestParseGatewayRequest_TypeValidation(t *testing.T) {
tests := []struct {
name string
body string
wantErr bool
errSubstr string // 期望的错误信息子串(为空则不检查)
}{
{
name: "model 为 int",
body: `{"model":123}`,
wantErr: true,
errSubstr: "invalid model field type",
},
{
name: "model 为 array",
body: `{"model":[]}`,
wantErr: true,
errSubstr: "invalid model field type",
},
{
name: "model 为 bool",
body: `{"model":true}`,
wantErr: true,
errSubstr: "invalid model field type",
},
{
name: "model 为 null — gjson Null 类型触发类型校验错误",
body: `{"model":null}`,
wantErr: true, // gjson: Exists()=true, Type=Null != String → 返回错误
errSubstr: "invalid model field type",
},
{
name: "stream 为 string",
body: `{"stream":"true"}`,
wantErr: true,
errSubstr: "invalid stream field type",
},
{
name: "stream 为 int",
body: `{"stream":1}`,
wantErr: true,
errSubstr: "invalid stream field type",
},
{
name: "stream 为 null — gjson Null 类型触发类型校验错误",
body: `{"stream":null}`,
wantErr: true, // gjson: Exists()=true, Type=Null != True && != False → 返回错误
errSubstr: "invalid stream field type",
},
{
name: "model 为 object",
body: `{"model":{}}`,
wantErr: true,
errSubstr: "invalid model field type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseGatewayRequest([]byte(tt.body), "")
if tt.wantErr {
require.Error(t, err)
if tt.errSubstr != "" {
require.Contains(t, err.Error(), tt.errSubstr)
}
} else {
require.NoError(t, err)
}
})
}
}
// Task 7.2 — 可选字段缺失测试
func TestParseGatewayRequest_OptionalFieldsMissing(t *testing.T) {
tests := []struct {
name string
body string
wantModel string
wantStream bool
wantMetadataUID string
wantHasSystem bool
wantThinking bool
wantMaxTokens int
wantMessagesNil bool
wantMessagesLen int
}{
{
name: "完全空 JSON — 所有字段零值",
body: `{}`,
wantModel: "",
wantStream: false,
wantMetadataUID: "",
wantHasSystem: false,
wantThinking: false,
wantMaxTokens: 0,
wantMessagesNil: true,
},
{
name: "metadata 无 user_id",
body: `{"model":"test"}`,
wantModel: "test",
wantMetadataUID: "",
wantHasSystem: false,
wantThinking: false,
},
{
name: "thinking 非 enabledtype=disabled",
body: `{"model":"test","thinking":{"type":"disabled"}}`,
wantModel: "test",
wantThinking: false,
},
{
name: "thinking 字段缺失",
body: `{"model":"test"}`,
wantModel: "test",
wantThinking: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parsed, err := ParseGatewayRequest([]byte(tt.body), "")
require.NoError(t, err)
require.Equal(t, tt.wantModel, parsed.Model)
require.Equal(t, tt.wantStream, parsed.Stream)
require.Equal(t, tt.wantMetadataUID, parsed.MetadataUserID)
require.Equal(t, tt.wantHasSystem, parsed.HasSystem)
require.Equal(t, tt.wantThinking, parsed.ThinkingEnabled)
require.Equal(t, tt.wantMaxTokens, parsed.MaxTokens)
if tt.wantMessagesNil {
require.Nil(t, parsed.Messages)
}
if tt.wantMessagesLen > 0 {
require.Len(t, parsed.Messages, tt.wantMessagesLen)
}
})
}
}
// Task 7.3 — Gemini 协议分支测试
// 已有测试覆盖:
// - TestParseGatewayRequest_GeminiSystemInstruction: 正常 systemInstruction+contents
// - TestParseGatewayRequest_GeminiNoContents: 缺失 contents
// - TestParseGatewayRequest_GeminiContents: 正常 contents无 systemInstruction
// 因此跳过。
// Task 7.4 — max_tokens 边界测试
func TestParseGatewayRequest_MaxTokensBoundary(t *testing.T) {
tests := []struct {
name string
body string
wantMaxTokens int
wantErr bool
}{
{
name: "正常整数",
body: `{"max_tokens":1024}`,
wantMaxTokens: 1024,
},
{
name: "浮点数(非整数)被忽略",
body: `{"max_tokens":10.5}`,
wantMaxTokens: 0,
},
{
name: "负整数可以通过",
body: `{"max_tokens":-1}`,
wantMaxTokens: -1,
},
{
name: "超大值不 panic",
body: `{"max_tokens":9999999999999999}`,
wantMaxTokens: 10000000000000000, // float64 精度导致 9999999999999999 → 1e16
},
{
name: "null 值被忽略",
body: `{"max_tokens":null}`,
wantMaxTokens: 0, // gjson Type=Null != Number → 条件不满足,跳过
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parsed, err := ParseGatewayRequest([]byte(tt.body), "")
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantMaxTokens, parsed.MaxTokens)
})
}
}
// ============ Task 7.5: Benchmark 测试 ============
// parseGatewayRequestOld 是基于完整 json.Unmarshal 的旧实现,用于 benchmark 对比基线。
// 核心路径:先 Unmarshal 到 map[string]any再逐字段提取。
func parseGatewayRequestOld(body []byte, protocol string) (*ParsedRequest, error) {
parsed := &ParsedRequest{
Body: body,
}
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
// model
if raw, ok := req["model"]; ok {
s, ok := raw.(string)
if !ok {
return nil, fmt.Errorf("invalid model field type")
}
parsed.Model = s
}
// stream
if raw, ok := req["stream"]; ok {
b, ok := raw.(bool)
if !ok {
return nil, fmt.Errorf("invalid stream field type")
}
parsed.Stream = b
}
// metadata.user_id
if meta, ok := req["metadata"].(map[string]any); ok {
if uid, ok := meta["user_id"].(string); ok {
parsed.MetadataUserID = uid
}
}
// thinking.type
if thinking, ok := req["thinking"].(map[string]any); ok {
if thinkType, ok := thinking["type"].(string); ok && thinkType == "enabled" {
parsed.ThinkingEnabled = true
}
}
// max_tokens
if raw, ok := req["max_tokens"]; ok {
if n, ok := parseIntegralNumber(raw); ok {
parsed.MaxTokens = n
}
}
// system / messages按协议分支
switch protocol {
case domain.PlatformGemini:
if sysInst, ok := req["systemInstruction"].(map[string]any); ok {
if parts, ok := sysInst["parts"].([]any); ok {
parsed.System = parts
}
}
if contents, ok := req["contents"].([]any); ok {
parsed.Messages = contents
}
default:
if system, ok := req["system"]; ok {
parsed.HasSystem = true
parsed.System = system
}
if messages, ok := req["messages"].([]any); ok {
parsed.Messages = messages
}
}
return parsed, nil
}
// buildSmallJSON 构建 ~500B 的小型测试 JSON
func buildSmallJSON() []byte {
return []byte(`{"model":"claude-sonnet-4-5","stream":true,"max_tokens":4096,"metadata":{"user_id":"user-abc123"},"thinking":{"type":"enabled","budget_tokens":2048},"system":"You are a helpful assistant.","messages":[{"role":"user","content":"What is the meaning of life?"},{"role":"assistant","content":"The meaning of life is a philosophical question."},{"role":"user","content":"Can you elaborate?"}]}`)
}
// buildLargeJSON 构建 ~50KB 的大型测试 JSON大量 messages
func buildLargeJSON() []byte {
var b strings.Builder
b.WriteString(`{"model":"claude-sonnet-4-5","stream":true,"max_tokens":8192,"metadata":{"user_id":"user-xyz789"},"system":[{"type":"text","text":"You are a detailed assistant.","cache_control":{"type":"ephemeral"}}],"messages":[`)
msgCount := 200
for i := 0; i < msgCount; i++ {
if i > 0 {
b.WriteByte(',')
}
if i%2 == 0 {
b.WriteString(fmt.Sprintf(`{"role":"user","content":"This is user message number %d with some extra padding text to make the message reasonably long for benchmarking purposes. Lorem ipsum dolor sit amet."}`, i))
} else {
b.WriteString(fmt.Sprintf(`{"role":"assistant","content":[{"type":"text","text":"This is assistant response number %d. I will provide a detailed answer with multiple sentences to simulate real conversation content for benchmark testing."}]}`, i))
}
}
b.WriteString(`]}`)
return []byte(b.String())
}
func BenchmarkParseGatewayRequest_Old_Small(b *testing.B) {
data := buildSmallJSON()
b.SetBytes(int64(len(data)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = parseGatewayRequestOld(data, "")
}
}
func BenchmarkParseGatewayRequest_New_Small(b *testing.B) {
data := buildSmallJSON()
b.SetBytes(int64(len(data)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ParseGatewayRequest(data, "")
}
}
func BenchmarkParseGatewayRequest_Old_Large(b *testing.B) {
data := buildLargeJSON()
b.SetBytes(int64(len(data)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = parseGatewayRequestOld(data, "")
}
}
func TestParseGatewayRequest_OutputEffort(t *testing.T) {
tests := []struct {
name string
body string
wantEffort string
}{
{
name: "output_config.effort present",
body: `{"model":"claude-opus-4-6","output_config":{"effort":"medium"},"messages":[]}`,
wantEffort: "medium",
},
{
name: "output_config.effort max",
body: `{"model":"claude-opus-4-6","output_config":{"effort":"max"},"messages":[]}`,
wantEffort: "max",
},
{
name: "output_config without effort",
body: `{"model":"claude-opus-4-6","output_config":{},"messages":[]}`,
wantEffort: "",
},
{
name: "no output_config",
body: `{"model":"claude-opus-4-6","messages":[]}`,
wantEffort: "",
},
{
name: "effort with whitespace trimmed",
body: `{"model":"claude-opus-4-6","output_config":{"effort":" high "},"messages":[]}`,
wantEffort: "high",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parsed, err := ParseGatewayRequest([]byte(tt.body), "")
require.NoError(t, err)
require.Equal(t, tt.wantEffort, parsed.OutputEffort)
})
}
}
func TestNormalizeClaudeOutputEffort(t *testing.T) {
tests := []struct {
input string
want *string
}{
{"low", strPtr("low")},
{"medium", strPtr("medium")},
{"high", strPtr("high")},
{"max", strPtr("max")},
{"LOW", strPtr("low")},
{"Max", strPtr("max")},
{" medium ", strPtr("medium")},
{"", nil},
{"unknown", nil},
{"xhigh", nil},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := NormalizeClaudeOutputEffort(tt.input)
if tt.want == nil {
require.Nil(t, got)
} else {
require.NotNil(t, got)
require.Equal(t, *tt.want, *got)
}
})
}
}
func BenchmarkParseGatewayRequest_New_Large(b *testing.B) {
data := buildLargeJSON()
b.SetBytes(int64(len(data)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ParseGatewayRequest(data, "")
}
}