fix: 修复 /v1/messages 间歇性 400 错误 (#112)
* fix(upstream): 修复上游格式兼容性问题 - 跳过Claude模型无signature的thinking block - 支持custom类型工具(MCP)格式转换 - 添加ClaudeCustomToolSpec结构体支持MCP工具 - 添加Custom字段验证,跳过无效custom工具 - 在convertClaudeToolsToGeminiTools中添加schema清理 - 完整的单元测试覆盖,包含边界情况 修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式 改进: Codex审查发现的2个重要问题 测试: - TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理 - TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况 - TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换 * feat(gemini): 添加Gemini限额与TierID支持 实现PR1:Gemini限额与TierID功能 后端修改: - GeminiTokenInfo结构体添加TierID字段 - fetchProjectID函数返回(projectID, tierID, error) - 从LoadCodeAssist响应中提取tierID(优先IsDefault,回退到第一个非空tier) - ExchangeCode、RefreshAccountToken、GetAccessToken函数更新以处理tierID - BuildAccountCredentials函数保存tier_id到credentials 前端修改: - AccountStatusIndicator组件添加tier显示 - 支持LEGACY/PRO/ULTRA等tier类型的友好显示 - 使用蓝色badge展示tier信息 技术细节: - tierID提取逻辑:优先选择IsDefault的tier,否则选择第一个非空tier - 所有fetchProjectID调用点已更新以处理新的返回签名 - 前端gracefully处理missing/unknown tier_id * refactor(gemini): 优化TierID实现并添加安全验证 根据并发代码审查(code-reviewer, security-auditor, gemini, codex)的反馈进行改进: 安全改进: - 添加validateTierID函数验证tier_id格式和长度(最大64字符) - 限制tier_id字符集为字母数字、下划线、连字符和斜杠 - 在BuildAccountCredentials中验证tier_id后再存储 - 静默跳过无效tier_id,不阻塞账户创建 代码质量改进: - 提取extractTierIDFromAllowedTiers辅助函数消除重复代码 - 重构fetchProjectID函数,tierID提取逻辑只执行一次 - 改进代码可读性和可维护性 审查工具: - code-reviewer agent (a09848e) - security-auditor agent (a9a149c) - gemini CLI (bcc7c81) - codex (b5d8919) 修复问题: - HIGH: 未验证的tier_id输入 - MEDIUM: 代码重复(tierID提取逻辑重复2次) * fix(format): 修复 gofmt 格式问题 - 修复 claude_types.go 中的字段对齐问题 - 修复 gemini_messages_compat_service.go 中的缩进问题 * fix(upstream): 修复上游格式兼容性问题 (#14) * fix(upstream): 修复上游格式兼容性问题 - 跳过Claude模型无signature的thinking block - 支持custom类型工具(MCP)格式转换 - 添加ClaudeCustomToolSpec结构体支持MCP工具 - 添加Custom字段验证,跳过无效custom工具 - 在convertClaudeToolsToGeminiTools中添加schema清理 - 完整的单元测试覆盖,包含边界情况 修复: Issue 0.1 signature缺失, Issue 0.2 custom工具格式 改进: Codex审查发现的2个重要问题 测试: - TestBuildParts_ThinkingBlockWithoutSignature: 验证thinking block处理 - TestBuildTools_CustomTypeTools: 验证custom工具转换和边界情况 - TestConvertClaudeToolsToGeminiTools_CustomType: 验证service层转换 * fix(format): 修复 gofmt 格式问题 - 修复 claude_types.go 中的字段对齐问题 - 修复 gemini_messages_compat_service.go 中的缩进问题 * fix(format): 修复 claude_types.go 的 gofmt 格式问题 * feat(antigravity): 优化 thinking block 和 schema 处理 - 为 dummy thinking block 添加 ThoughtSignature - 重构 thinking block 处理逻辑,在每个条件分支内创建 part - 优化 excludedSchemaKeys,移除 Gemini 实际支持的字段 (minItems, maxItems, minimum, maximum, additionalProperties, format) - 添加详细注释说明 Gemini API 支持的 schema 字段 * fix(antigravity): 增强 schema 清理的安全性 基于 Codex review 建议: - 添加 format 字段白名单过滤,只保留 Gemini 支持的 date-time/date/time - 补充更多不支持的 schema 关键字到黑名单: * 组合 schema: oneOf, anyOf, allOf, not, if/then/else * 对象验证: minProperties, maxProperties, patternProperties 等 * 定义引用: $defs, definitions - 避免不支持的 schema 字段导致 Gemini API 校验失败 * fix(lint): 修复 gemini_messages_compat_service 空分支警告 - 在 cleanToolSchema 的 if 语句中添加 continue - 移除重复的注释 * fix(antigravity): 移除 minItems/maxItems 以兼容 Claude API - 将 minItems 和 maxItems 添加到 schema 黑名单 - Claude API (Vertex AI) 不支持这些数组验证字段 - 添加调试日志记录工具 schema 转换过程 - 修复 tools.14.custom.input_schema 验证错误 * fix(antigravity): 修复 additionalProperties schema 对象问题 - 将 additionalProperties 的 schema 对象转换为布尔值 true - Claude API 只支持 additionalProperties: false,不支持 schema 对象 - 修复 tools.14.custom.input_schema 验证错误 - 参考 Claude 官方文档的 JSON Schema 限制 * fix(antigravity): 修复 Claude 模型 thinking 块兼容性问题 - 完全跳过 Claude 模型的 thinking 块以避免 signature 验证失败 - 只在 Gemini 模型中使用 dummy thought signature - 修改 additionalProperties 默认值为 false(更安全) - 添加调试日志以便排查问题 * fix(upstream): 修复跨模型切换时的 dummy signature 问题 基于 Codex review 和用户场景分析的修复: 1. 问题场景 - Gemini (thinking) → Claude (thinking) 切换时 - Gemini 返回的 thinking 块使用 dummy signature - Claude API 会拒绝 dummy signature,导致 400 错误 2. 修复内容 - request_transformer.go:262: 跳过 dummy signature - 只保留真实的 Claude signature - 支持频繁的跨模型切换 3. 其他修复(基于 Codex review) - gateway_service.go:691: 修复 io.ReadAll 错误处理 - gateway_service.go:687: 条件日志(尊重 LogUpstreamErrorBody 配置) - gateway_service.go:915: 收紧 400 failover 启发式 - request_transformer.go:188: 移除签名成功日志 4. 新增功能(默认关闭) - 阶段 1: 上游错误日志(GATEWAY_LOG_UPSTREAM_ERROR_BODY) - 阶段 2: Antigravity thinking 修复 - 阶段 3: API-key beta 注入(GATEWAY_INJECT_BETA_FOR_APIKEY) - 阶段 3: 智能 400 failover(GATEWAY_FAILOVER_ON_400) 测试:所有测试通过 * fix(lint): 修复 golangci-lint 问题 - 应用 De Morgan 定律简化条件判断 - 修复 gofmt 格式问题 - 移除未使用的 min 函数
This commit is contained in:
@@ -119,6 +119,17 @@ type GatewayConfig struct {
|
|||||||
// ConcurrencySlotTTLMinutes: 并发槽位过期时间(分钟)
|
// ConcurrencySlotTTLMinutes: 并发槽位过期时间(分钟)
|
||||||
// 应大于最长 LLM 请求时间,防止请求完成前槽位过期
|
// 应大于最长 LLM 请求时间,防止请求完成前槽位过期
|
||||||
ConcurrencySlotTTLMinutes int `mapstructure:"concurrency_slot_ttl_minutes"`
|
ConcurrencySlotTTLMinutes int `mapstructure:"concurrency_slot_ttl_minutes"`
|
||||||
|
|
||||||
|
// 是否记录上游错误响应体摘要(避免输出请求内容)
|
||||||
|
LogUpstreamErrorBody bool `mapstructure:"log_upstream_error_body"`
|
||||||
|
// 上游错误响应体记录最大字节数(超过会截断)
|
||||||
|
LogUpstreamErrorBodyMaxBytes int `mapstructure:"log_upstream_error_body_max_bytes"`
|
||||||
|
|
||||||
|
// API-key 账号在客户端未提供 anthropic-beta 时,是否按需自动补齐(默认关闭以保持兼容)
|
||||||
|
InjectBetaForApiKey bool `mapstructure:"inject_beta_for_apikey"`
|
||||||
|
|
||||||
|
// 是否允许对部分 400 错误触发 failover(默认关闭以避免改变语义)
|
||||||
|
FailoverOn400 bool `mapstructure:"failover_on_400"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServerConfig) Address() string {
|
func (s *ServerConfig) Address() string {
|
||||||
@@ -313,6 +324,10 @@ func setDefaults() {
|
|||||||
|
|
||||||
// Gateway
|
// Gateway
|
||||||
viper.SetDefault("gateway.response_header_timeout", 300) // 300秒(5分钟)等待上游响应头,LLM高负载时可能排队较久
|
viper.SetDefault("gateway.response_header_timeout", 300) // 300秒(5分钟)等待上游响应头,LLM高负载时可能排队较久
|
||||||
|
viper.SetDefault("gateway.log_upstream_error_body", false)
|
||||||
|
viper.SetDefault("gateway.log_upstream_error_body_max_bytes", 2048)
|
||||||
|
viper.SetDefault("gateway.inject_beta_for_apikey", false)
|
||||||
|
viper.SetDefault("gateway.failover_on_400", false)
|
||||||
viper.SetDefault("gateway.max_body_size", int64(100*1024*1024))
|
viper.SetDefault("gateway.max_body_size", int64(100*1024*1024))
|
||||||
viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy)
|
viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy)
|
||||||
// HTTP 上游连接池配置(针对 5000+ 并发用户优化)
|
// HTTP 上游连接池配置(针对 5000+ 并发用户优化)
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ type CustomToolSpec struct {
|
|||||||
InputSchema map[string]any `json:"input_schema"`
|
InputSchema map[string]any `json:"input_schema"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClaudeCustomToolSpec 兼容旧命名(MCP custom 工具规格)
|
||||||
|
type ClaudeCustomToolSpec = CustomToolSpec
|
||||||
|
|
||||||
// SystemBlock system prompt 数组形式的元素
|
// SystemBlock system prompt 数组形式的元素
|
||||||
type SystemBlock struct {
|
type SystemBlock struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|||||||
@@ -14,13 +14,16 @@ func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel st
|
|||||||
// 用于存储 tool_use id -> name 映射
|
// 用于存储 tool_use id -> name 映射
|
||||||
toolIDToName := make(map[string]string)
|
toolIDToName := make(map[string]string)
|
||||||
|
|
||||||
// 检测是否启用 thinking
|
|
||||||
isThinkingEnabled := claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled"
|
|
||||||
|
|
||||||
// 只有 Gemini 模型支持 dummy thought workaround
|
// 只有 Gemini 模型支持 dummy thought workaround
|
||||||
// Claude 模型通过 Vertex/Google API 需要有效的 thought signatures
|
// Claude 模型通过 Vertex/Google API 需要有效的 thought signatures
|
||||||
allowDummyThought := strings.HasPrefix(mappedModel, "gemini-")
|
allowDummyThought := strings.HasPrefix(mappedModel, "gemini-")
|
||||||
|
|
||||||
|
// 检测是否启用 thinking
|
||||||
|
requestedThinkingEnabled := claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled"
|
||||||
|
// 为避免 Claude 模型的 thought signature/消息块约束导致 400(上游要求 thinking 块开头等),
|
||||||
|
// 非 Gemini 模型默认不启用 thinking(除非未来支持完整签名链路)。
|
||||||
|
isThinkingEnabled := requestedThinkingEnabled && allowDummyThought
|
||||||
|
|
||||||
// 1. 构建 contents
|
// 1. 构建 contents
|
||||||
contents, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, allowDummyThought)
|
contents, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, allowDummyThought)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -31,7 +34,15 @@ func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel st
|
|||||||
systemInstruction := buildSystemInstruction(claudeReq.System, claudeReq.Model)
|
systemInstruction := buildSystemInstruction(claudeReq.System, claudeReq.Model)
|
||||||
|
|
||||||
// 3. 构建 generationConfig
|
// 3. 构建 generationConfig
|
||||||
generationConfig := buildGenerationConfig(claudeReq)
|
reqForGen := claudeReq
|
||||||
|
if requestedThinkingEnabled && !allowDummyThought {
|
||||||
|
log.Printf("[Warning] Disabling thinking for non-Gemini model in antigravity transform: model=%s", mappedModel)
|
||||||
|
// shallow copy to avoid mutating caller's request
|
||||||
|
clone := *claudeReq
|
||||||
|
clone.Thinking = nil
|
||||||
|
reqForGen = &clone
|
||||||
|
}
|
||||||
|
generationConfig := buildGenerationConfig(reqForGen)
|
||||||
|
|
||||||
// 4. 构建 tools
|
// 4. 构建 tools
|
||||||
tools := buildTools(claudeReq.Tools)
|
tools := buildTools(claudeReq.Tools)
|
||||||
@@ -150,6 +161,7 @@ func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isT
|
|||||||
parts = append([]GeminiPart{{
|
parts = append([]GeminiPart{{
|
||||||
Text: "Thinking...",
|
Text: "Thinking...",
|
||||||
Thought: true,
|
Thought: true,
|
||||||
|
ThoughtSignature: dummyThoughtSignature,
|
||||||
}}, parts...)
|
}}, parts...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,6 +183,34 @@ 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 是否有效
|
||||||
|
// Claude API 要求 signature 必须是 base64 编码的字符串,长度至少 32 字节
|
||||||
|
func isValidThoughtSignature(signature string) bool {
|
||||||
|
// 空字符串无效
|
||||||
|
if signature == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// signature 应该是 base64 编码,长度至少 40 个字符(约 30 字节)
|
||||||
|
// 参考 Claude API 文档和实际观察到的有效 signature
|
||||||
|
if len(signature) < 40 {
|
||||||
|
log.Printf("[Debug] Signature too short: len=%d", len(signature))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是有效的 base64 字符
|
||||||
|
// base64 字符集: A-Z, a-z, 0-9, +, /, =
|
||||||
|
for i, c := range signature {
|
||||||
|
if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') &&
|
||||||
|
(c < '0' || c > '9') && c != '+' && c != '/' && c != '=' {
|
||||||
|
log.Printf("[Debug] Invalid base64 character at position %d: %c (code=%d)", i, c, c)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// buildParts 构建消息的 parts
|
// buildParts 构建消息的 parts
|
||||||
// allowDummyThought: 只有 Gemini 模型支持 dummy thought signature
|
// allowDummyThought: 只有 Gemini 模型支持 dummy thought signature
|
||||||
func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDummyThought bool) ([]GeminiPart, error) {
|
func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDummyThought bool) ([]GeminiPart, error) {
|
||||||
@@ -199,22 +239,30 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "thinking":
|
case "thinking":
|
||||||
part := GeminiPart{
|
if allowDummyThought {
|
||||||
|
// Gemini 模型可以使用 dummy signature
|
||||||
|
parts = append(parts, GeminiPart{
|
||||||
Text: block.Thinking,
|
Text: block.Thinking,
|
||||||
Thought: true,
|
Thought: true,
|
||||||
}
|
ThoughtSignature: dummyThoughtSignature,
|
||||||
// 保留原有 signature(Claude 模型需要有效的 signature)
|
})
|
||||||
if block.Signature != "" {
|
|
||||||
part.ThoughtSignature = block.Signature
|
|
||||||
} else if !allowDummyThought {
|
|
||||||
// Claude 模型需要有效 signature,跳过无 signature 的 thinking block
|
|
||||||
log.Printf("Warning: skipping thinking block without signature for Claude model")
|
|
||||||
continue
|
continue
|
||||||
} else {
|
|
||||||
// Gemini 模型使用 dummy signature
|
|
||||||
part.ThoughtSignature = dummyThoughtSignature
|
|
||||||
}
|
}
|
||||||
parts = append(parts, part)
|
|
||||||
|
// Claude 模型:仅在提供有效 signature 时保留 thinking block;否则跳过以避免上游校验失败。
|
||||||
|
signature := strings.TrimSpace(block.Signature)
|
||||||
|
if signature == "" || signature == dummyThoughtSignature {
|
||||||
|
log.Printf("[Warning] Skipping thinking block for Claude model (missing or dummy signature)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isValidThoughtSignature(signature) {
|
||||||
|
log.Printf("[Debug] Thinking signature may be invalid (passing through anyway): len=%d", len(signature))
|
||||||
|
}
|
||||||
|
parts = append(parts, GeminiPart{
|
||||||
|
Text: block.Thinking,
|
||||||
|
Thought: true,
|
||||||
|
ThoughtSignature: signature,
|
||||||
|
})
|
||||||
|
|
||||||
case "image":
|
case "image":
|
||||||
if block.Source != nil && block.Source.Type == "base64" {
|
if block.Source != nil && block.Source.Type == "base64" {
|
||||||
@@ -239,10 +287,9 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
|
|||||||
ID: block.ID,
|
ID: block.ID,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// 保留原有 signature,或对 Gemini 模型使用 dummy signature
|
// 只有 Gemini 模型使用 dummy signature
|
||||||
if block.Signature != "" {
|
// Claude 模型不设置 signature(避免验证问题)
|
||||||
part.ThoughtSignature = block.Signature
|
if allowDummyThought {
|
||||||
} else if allowDummyThought {
|
|
||||||
part.ThoughtSignature = dummyThoughtSignature
|
part.ThoughtSignature = dummyThoughtSignature
|
||||||
}
|
}
|
||||||
parts = append(parts, part)
|
parts = append(parts, part)
|
||||||
@@ -386,9 +433,9 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
|||||||
|
|
||||||
// 普通工具
|
// 普通工具
|
||||||
var funcDecls []GeminiFunctionDecl
|
var funcDecls []GeminiFunctionDecl
|
||||||
for _, tool := range tools {
|
for i, tool := range tools {
|
||||||
// 跳过无效工具名称
|
// 跳过无效工具名称
|
||||||
if tool.Name == "" {
|
if strings.TrimSpace(tool.Name) == "" {
|
||||||
log.Printf("Warning: skipping tool with empty name")
|
log.Printf("Warning: skipping tool with empty name")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -397,10 +444,18 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
|||||||
var inputSchema map[string]any
|
var inputSchema map[string]any
|
||||||
|
|
||||||
// 检查是否为 custom 类型工具 (MCP)
|
// 检查是否为 custom 类型工具 (MCP)
|
||||||
if tool.Type == "custom" && tool.Custom != nil {
|
if tool.Type == "custom" {
|
||||||
// Custom 格式: 从 custom 字段获取 description 和 input_schema
|
if tool.Custom == nil || tool.Custom.InputSchema == nil {
|
||||||
|
log.Printf("[Warning] Skipping invalid custom tool '%s': missing custom spec or input_schema", tool.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
description = tool.Custom.Description
|
description = tool.Custom.Description
|
||||||
inputSchema = tool.Custom.InputSchema
|
inputSchema = tool.Custom.InputSchema
|
||||||
|
|
||||||
|
// 调试日志:记录 custom 工具的 schema
|
||||||
|
if schemaJSON, err := json.Marshal(inputSchema); err == nil {
|
||||||
|
log.Printf("[Debug] Tool[%d] '%s' (custom) original schema: %s", i, tool.Name, string(schemaJSON))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 标准格式: 从顶层字段获取
|
// 标准格式: 从顶层字段获取
|
||||||
description = tool.Description
|
description = tool.Description
|
||||||
@@ -409,7 +464,6 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
|||||||
|
|
||||||
// 清理 JSON Schema
|
// 清理 JSON Schema
|
||||||
params := cleanJSONSchema(inputSchema)
|
params := cleanJSONSchema(inputSchema)
|
||||||
|
|
||||||
// 为 nil schema 提供默认值
|
// 为 nil schema 提供默认值
|
||||||
if params == nil {
|
if params == nil {
|
||||||
params = map[string]any{
|
params = map[string]any{
|
||||||
@@ -418,6 +472,11 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 调试日志:记录清理后的 schema
|
||||||
|
if paramsJSON, err := json.Marshal(params); err == nil {
|
||||||
|
log.Printf("[Debug] Tool[%d] '%s' cleaned schema: %s", i, tool.Name, string(paramsJSON))
|
||||||
|
}
|
||||||
|
|
||||||
funcDecls = append(funcDecls, GeminiFunctionDecl{
|
funcDecls = append(funcDecls, GeminiFunctionDecl{
|
||||||
Name: tool.Name,
|
Name: tool.Name,
|
||||||
Description: description,
|
Description: description,
|
||||||
@@ -479,24 +538,54 @@ func cleanJSONSchema(schema map[string]any) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// excludedSchemaKeys 不支持的 schema 字段
|
// excludedSchemaKeys 不支持的 schema 字段
|
||||||
|
// 基于 Claude API (Vertex AI) 的实际支持情况
|
||||||
|
// 支持: type, description, enum, properties, required, additionalProperties, items
|
||||||
|
// 不支持: minItems, maxItems, minLength, maxLength, pattern, minimum, maximum 等验证字段
|
||||||
var excludedSchemaKeys = map[string]bool{
|
var excludedSchemaKeys = map[string]bool{
|
||||||
|
// 元 schema 字段
|
||||||
"$schema": true,
|
"$schema": true,
|
||||||
"$id": true,
|
"$id": true,
|
||||||
"$ref": true,
|
"$ref": true,
|
||||||
"additionalProperties": true,
|
|
||||||
|
// 字符串验证(Gemini 不支持)
|
||||||
"minLength": true,
|
"minLength": true,
|
||||||
"maxLength": true,
|
"maxLength": true,
|
||||||
"minItems": true,
|
"pattern": true,
|
||||||
"maxItems": true,
|
|
||||||
"uniqueItems": true,
|
// 数字验证(Claude API 通过 Vertex AI 不支持这些字段)
|
||||||
"minimum": true,
|
"minimum": true,
|
||||||
"maximum": true,
|
"maximum": true,
|
||||||
"exclusiveMinimum": true,
|
"exclusiveMinimum": true,
|
||||||
"exclusiveMaximum": true,
|
"exclusiveMaximum": true,
|
||||||
"pattern": true,
|
"multipleOf": true,
|
||||||
"format": true,
|
|
||||||
|
// 数组验证(Claude API 通过 Vertex AI 不支持这些字段)
|
||||||
|
"uniqueItems": true,
|
||||||
|
"minItems": true,
|
||||||
|
"maxItems": true,
|
||||||
|
|
||||||
|
// 组合 schema(Gemini 不支持)
|
||||||
|
"oneOf": true,
|
||||||
|
"anyOf": true,
|
||||||
|
"allOf": true,
|
||||||
|
"not": true,
|
||||||
|
"if": true,
|
||||||
|
"then": true,
|
||||||
|
"else": true,
|
||||||
|
"$defs": true,
|
||||||
|
"definitions": true,
|
||||||
|
|
||||||
|
// 对象验证(仅保留 properties/required/additionalProperties)
|
||||||
|
"minProperties": true,
|
||||||
|
"maxProperties": true,
|
||||||
|
"patternProperties": true,
|
||||||
|
"propertyNames": true,
|
||||||
|
"dependencies": true,
|
||||||
|
"dependentSchemas": true,
|
||||||
|
"dependentRequired": true,
|
||||||
|
|
||||||
|
// 其他不支持的字段
|
||||||
"default": true,
|
"default": true,
|
||||||
"strict": true,
|
|
||||||
"const": true,
|
"const": true,
|
||||||
"examples": true,
|
"examples": true,
|
||||||
"deprecated": true,
|
"deprecated": true,
|
||||||
@@ -504,6 +593,9 @@ var excludedSchemaKeys = map[string]bool{
|
|||||||
"writeOnly": true,
|
"writeOnly": true,
|
||||||
"contentMediaType": true,
|
"contentMediaType": true,
|
||||||
"contentEncoding": true,
|
"contentEncoding": true,
|
||||||
|
|
||||||
|
// Claude 特有字段
|
||||||
|
"strict": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanSchemaValue 递归清理 schema 值
|
// cleanSchemaValue 递归清理 schema 值
|
||||||
@@ -523,6 +615,31 @@ func cleanSchemaValue(value any) any {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 特殊处理 format 字段:只保留 Gemini 支持的 format 值
|
||||||
|
if k == "format" {
|
||||||
|
if formatStr, ok := val.(string); ok {
|
||||||
|
// Gemini 只支持 date-time, date, time
|
||||||
|
if formatStr == "date-time" || formatStr == "date" || formatStr == "time" {
|
||||||
|
result[k] = val
|
||||||
|
}
|
||||||
|
// 其他 format 值直接跳过
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 特殊处理 additionalProperties:Claude API 只支持布尔值,不支持 schema 对象
|
||||||
|
if k == "additionalProperties" {
|
||||||
|
if boolVal, ok := val.(bool); ok {
|
||||||
|
result[k] = boolVal
|
||||||
|
log.Printf("[Debug] additionalProperties is bool: %v", boolVal)
|
||||||
|
} else {
|
||||||
|
// 如果是 schema 对象,转换为 false(更安全的默认值)
|
||||||
|
result[k] = false
|
||||||
|
log.Printf("[Debug] additionalProperties is not bool (type: %T), converting to false", val)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 递归清理所有值
|
// 递归清理所有值
|
||||||
result[k] = cleanSchemaValue(val)
|
result[k] = cleanSchemaValue(val)
|
||||||
}
|
}
|
||||||
|
|||||||
179
backend/internal/pkg/antigravity/request_transformer_test.go
Normal file
179
backend/internal/pkg/antigravity/request_transformer_test.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package antigravity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBuildParts_ThinkingBlockWithoutSignature 测试thinking block无signature时的处理
|
||||||
|
func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
allowDummyThought bool
|
||||||
|
expectedParts int
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Claude model - skip thinking block without signature",
|
||||||
|
content: `[
|
||||||
|
{"type": "text", "text": "Hello"},
|
||||||
|
{"type": "thinking", "thinking": "Let me think...", "signature": ""},
|
||||||
|
{"type": "text", "text": "World"}
|
||||||
|
]`,
|
||||||
|
allowDummyThought: false,
|
||||||
|
expectedParts: 2, // 只有两个text block
|
||||||
|
description: "Claude模型应该跳过无signature的thinking block",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Claude model - keep thinking block with signature",
|
||||||
|
content: `[
|
||||||
|
{"type": "text", "text": "Hello"},
|
||||||
|
{"type": "thinking", "thinking": "Let me think...", "signature": "valid_sig"},
|
||||||
|
{"type": "text", "text": "World"}
|
||||||
|
]`,
|
||||||
|
allowDummyThought: false,
|
||||||
|
expectedParts: 3, // 三个block都保留
|
||||||
|
description: "Claude模型应该保留有signature的thinking block",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Gemini model - use dummy signature",
|
||||||
|
content: `[
|
||||||
|
{"type": "text", "text": "Hello"},
|
||||||
|
{"type": "thinking", "thinking": "Let me think...", "signature": ""},
|
||||||
|
{"type": "text", "text": "World"}
|
||||||
|
]`,
|
||||||
|
allowDummyThought: true,
|
||||||
|
expectedParts: 3, // 三个block都保留,thinking使用dummy signature
|
||||||
|
description: "Gemini模型应该为无signature的thinking block使用dummy signature",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
toolIDToName := make(map[string]string)
|
||||||
|
parts, err := buildParts(json.RawMessage(tt.content), toolIDToName, tt.allowDummyThought)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildParts() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) != tt.expectedParts {
|
||||||
|
t.Errorf("%s: got %d parts, want %d parts", tt.description, len(parts), tt.expectedParts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildTools_CustomTypeTools 测试custom类型工具转换
|
||||||
|
func TestBuildTools_CustomTypeTools(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tools []ClaudeTool
|
||||||
|
expectedLen int
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Standard tool format",
|
||||||
|
tools: []ClaudeTool{
|
||||||
|
{
|
||||||
|
Name: "get_weather",
|
||||||
|
Description: "Get weather information",
|
||||||
|
InputSchema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"location": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedLen: 1,
|
||||||
|
description: "标准工具格式应该正常转换",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Custom type tool (MCP format)",
|
||||||
|
tools: []ClaudeTool{
|
||||||
|
{
|
||||||
|
Type: "custom",
|
||||||
|
Name: "mcp_tool",
|
||||||
|
Custom: &ClaudeCustomToolSpec{
|
||||||
|
Description: "MCP tool description",
|
||||||
|
InputSchema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"param": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedLen: 1,
|
||||||
|
description: "Custom类型工具应该从Custom字段读取description和input_schema",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed standard and custom tools",
|
||||||
|
tools: []ClaudeTool{
|
||||||
|
{
|
||||||
|
Name: "standard_tool",
|
||||||
|
Description: "Standard tool",
|
||||||
|
InputSchema: map[string]any{"type": "object"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "custom",
|
||||||
|
Name: "custom_tool",
|
||||||
|
Custom: &ClaudeCustomToolSpec{
|
||||||
|
Description: "Custom tool",
|
||||||
|
InputSchema: map[string]any{"type": "object"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedLen: 1, // 返回一个GeminiToolDeclaration,包含2个function declarations
|
||||||
|
description: "混合标准和custom工具应该都能正确转换",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid custom tool - nil Custom field",
|
||||||
|
tools: []ClaudeTool{
|
||||||
|
{
|
||||||
|
Type: "custom",
|
||||||
|
Name: "invalid_custom",
|
||||||
|
// Custom 为 nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedLen: 0, // 应该被跳过
|
||||||
|
description: "Custom字段为nil的custom工具应该被跳过",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid custom tool - nil InputSchema",
|
||||||
|
tools: []ClaudeTool{
|
||||||
|
{
|
||||||
|
Type: "custom",
|
||||||
|
Name: "invalid_custom",
|
||||||
|
Custom: &ClaudeCustomToolSpec{
|
||||||
|
Description: "Invalid",
|
||||||
|
// InputSchema 为 nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedLen: 0, // 应该被跳过
|
||||||
|
description: "InputSchema为nil的custom工具应该被跳过",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := buildTools(tt.tools)
|
||||||
|
|
||||||
|
if len(result) != tt.expectedLen {
|
||||||
|
t.Errorf("%s: got %d tool declarations, want %d", tt.description, len(result), tt.expectedLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证function declarations存在
|
||||||
|
if len(result) > 0 && result[0].FunctionDeclarations != nil {
|
||||||
|
if len(result[0].FunctionDeclarations) != len(tt.tools) {
|
||||||
|
t.Errorf("%s: got %d function declarations, want %d",
|
||||||
|
tt.description, len(result[0].FunctionDeclarations), len(tt.tools))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,12 @@ const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleav
|
|||||||
// HaikuBetaHeader Haiku 模型使用的 anthropic-beta header(不需要 claude-code beta)
|
// HaikuBetaHeader Haiku 模型使用的 anthropic-beta header(不需要 claude-code beta)
|
||||||
const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking
|
const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking
|
||||||
|
|
||||||
|
// ApiKeyBetaHeader API-key 账号建议使用的 anthropic-beta header(不包含 oauth)
|
||||||
|
const ApiKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming
|
||||||
|
|
||||||
|
// ApiKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不包含 oauth / claude-code)
|
||||||
|
const ApiKeyHaikuBetaHeader = BetaInterleavedThinking
|
||||||
|
|
||||||
// Claude Code 客户端默认请求头
|
// Claude Code 客户端默认请求头
|
||||||
var DefaultHeaders = map[string]string{
|
var DefaultHeaders = map[string]string{
|
||||||
"User-Agent": "claude-cli/2.0.62 (external, cli)",
|
"User-Agent": "claude-cli/2.0.62 (external, cli)",
|
||||||
|
|||||||
@@ -358,6 +358,15 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
return nil, fmt.Errorf("transform request: %w", err)
|
return nil, fmt.Errorf("transform request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 调试:记录转换后的请求体(仅记录前 2000 字符)
|
||||||
|
if bodyJSON, err := json.Marshal(geminiBody); err == nil {
|
||||||
|
truncated := string(bodyJSON)
|
||||||
|
if len(truncated) > 2000 {
|
||||||
|
truncated = truncated[:2000] + "..."
|
||||||
|
}
|
||||||
|
log.Printf("[Debug] Transformed Gemini request: %s", truncated)
|
||||||
|
}
|
||||||
|
|
||||||
// 构建上游 action
|
// 构建上游 action
|
||||||
action := "generateContent"
|
action := "generateContent"
|
||||||
if claudeReq.Stream {
|
if claudeReq.Stream {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -684,6 +685,30 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
|||||||
|
|
||||||
// 处理错误响应(不可重试的错误)
|
// 处理错误响应(不可重试的错误)
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
|
// 可选:对部分 400 触发 failover(默认关闭以保持语义)
|
||||||
|
if resp.StatusCode == 400 && s.cfg != nil && s.cfg.Gateway.FailoverOn400 {
|
||||||
|
respBody, readErr := io.ReadAll(resp.Body)
|
||||||
|
if readErr != nil {
|
||||||
|
// ReadAll failed, fall back to normal error handling without consuming the stream
|
||||||
|
return s.handleErrorResponse(ctx, resp, c, account)
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||||
|
|
||||||
|
if s.shouldFailoverOn400(respBody) {
|
||||||
|
if s.cfg.Gateway.LogUpstreamErrorBody {
|
||||||
|
log.Printf(
|
||||||
|
"Account %d: 400 error, attempting failover: %s",
|
||||||
|
account.ID,
|
||||||
|
truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
log.Printf("Account %d: 400 error, attempting failover", account.ID)
|
||||||
|
}
|
||||||
|
s.handleFailoverSideEffects(ctx, resp, account)
|
||||||
|
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
||||||
|
}
|
||||||
|
}
|
||||||
return s.handleErrorResponse(ctx, resp, c, account)
|
return s.handleErrorResponse(ctx, resp, c, account)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,6 +811,13 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
|||||||
// 处理anthropic-beta header(OAuth账号需要特殊处理)
|
// 处理anthropic-beta header(OAuth账号需要特殊处理)
|
||||||
if tokenType == "oauth" {
|
if tokenType == "oauth" {
|
||||||
req.Header.Set("anthropic-beta", s.getBetaHeader(modelID, c.GetHeader("anthropic-beta")))
|
req.Header.Set("anthropic-beta", s.getBetaHeader(modelID, c.GetHeader("anthropic-beta")))
|
||||||
|
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForApiKey && req.Header.Get("anthropic-beta") == "" {
|
||||||
|
// API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭)
|
||||||
|
if requestNeedsBetaFeatures(body) {
|
||||||
|
if beta := defaultApiKeyBetaHeader(body); beta != "" {
|
||||||
|
req.Header.Set("anthropic-beta", beta)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return req, nil
|
return req, nil
|
||||||
@@ -838,6 +870,83 @@ func (s *GatewayService) getBetaHeader(modelID string, clientBetaHeader string)
|
|||||||
return claude.DefaultBetaHeader
|
return claude.DefaultBetaHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requestNeedsBetaFeatures(body []byte) bool {
|
||||||
|
tools := gjson.GetBytes(body, "tools")
|
||||||
|
if tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.EqualFold(gjson.GetBytes(body, "thinking.type").String(), "enabled") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultApiKeyBetaHeader(body []byte) string {
|
||||||
|
modelID := gjson.GetBytes(body, "model").String()
|
||||||
|
if strings.Contains(strings.ToLower(modelID), "haiku") {
|
||||||
|
return claude.ApiKeyHaikuBetaHeader
|
||||||
|
}
|
||||||
|
return claude.ApiKeyBetaHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateForLog(b []byte, maxBytes int) string {
|
||||||
|
if maxBytes <= 0 {
|
||||||
|
maxBytes = 2048
|
||||||
|
}
|
||||||
|
if len(b) > maxBytes {
|
||||||
|
b = b[:maxBytes]
|
||||||
|
}
|
||||||
|
s := string(b)
|
||||||
|
// 保持一行,避免污染日志格式
|
||||||
|
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||||
|
s = strings.ReplaceAll(s, "\r", "\\r")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GatewayService) shouldFailoverOn400(respBody []byte) bool {
|
||||||
|
// 只对“可能是兼容性差异导致”的 400 允许切换,避免无意义重试。
|
||||||
|
// 默认保守:无法识别则不切换。
|
||||||
|
msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
|
||||||
|
if msg == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缺少/错误的 beta header:换账号/链路可能成功(尤其是混合调度时)。
|
||||||
|
// 更精确匹配 beta 相关的兼容性问题,避免误触发切换。
|
||||||
|
if strings.Contains(msg, "anthropic-beta") ||
|
||||||
|
strings.Contains(msg, "beta feature") ||
|
||||||
|
strings.Contains(msg, "requires beta") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// thinking/tool streaming 等兼容性约束(常见于中间转换链路)
|
||||||
|
if strings.Contains(msg, "thinking") || strings.Contains(msg, "thought_signature") || strings.Contains(msg, "signature") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "tool_use") || strings.Contains(msg, "tool_result") || strings.Contains(msg, "tools") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractUpstreamErrorMessage(body []byte) string {
|
||||||
|
// Claude 风格:{"type":"error","error":{"type":"...","message":"..."}}
|
||||||
|
if m := gjson.GetBytes(body, "error.message").String(); strings.TrimSpace(m) != "" {
|
||||||
|
inner := strings.TrimSpace(m)
|
||||||
|
// 有些上游会把完整 JSON 作为字符串塞进 message
|
||||||
|
if strings.HasPrefix(inner, "{") {
|
||||||
|
if innerMsg := gjson.Get(inner, "error.message").String(); strings.TrimSpace(innerMsg) != "" {
|
||||||
|
return innerMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:尝试顶层 message
|
||||||
|
return gjson.GetBytes(body, "message").String()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account) (*ForwardResult, error) {
|
func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account) (*ForwardResult, error) {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
@@ -850,6 +959,16 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res
|
|||||||
|
|
||||||
switch resp.StatusCode {
|
switch resp.StatusCode {
|
||||||
case 400:
|
case 400:
|
||||||
|
// 仅记录上游错误摘要(避免输出请求内容);需要时可通过配置打开
|
||||||
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||||
|
log.Printf(
|
||||||
|
"Upstream 400 error (account=%d platform=%s type=%s): %s",
|
||||||
|
account.ID,
|
||||||
|
account.Platform,
|
||||||
|
account.Type,
|
||||||
|
truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
|
||||||
|
)
|
||||||
|
}
|
||||||
c.Data(http.StatusBadRequest, "application/json", body)
|
c.Data(http.StatusBadRequest, "application/json", body)
|
||||||
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
|
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
|
||||||
case 401:
|
case 401:
|
||||||
@@ -1329,6 +1448,18 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
|||||||
// 标记账号状态(429/529等)
|
// 标记账号状态(429/529等)
|
||||||
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
||||||
|
|
||||||
|
// 记录上游错误摘要便于排障(不回显请求内容)
|
||||||
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||||
|
log.Printf(
|
||||||
|
"count_tokens upstream error %d (account=%d platform=%s type=%s): %s",
|
||||||
|
resp.StatusCode,
|
||||||
|
account.ID,
|
||||||
|
account.Platform,
|
||||||
|
account.Type,
|
||||||
|
truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 返回简化的错误响应
|
// 返回简化的错误响应
|
||||||
errMsg := "Upstream request failed"
|
errMsg := "Upstream request failed"
|
||||||
switch resp.StatusCode {
|
switch resp.StatusCode {
|
||||||
@@ -1409,6 +1540,13 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
|
|||||||
// OAuth 账号:处理 anthropic-beta header
|
// OAuth 账号:处理 anthropic-beta header
|
||||||
if tokenType == "oauth" {
|
if tokenType == "oauth" {
|
||||||
req.Header.Set("anthropic-beta", s.getBetaHeader(modelID, c.GetHeader("anthropic-beta")))
|
req.Header.Set("anthropic-beta", s.getBetaHeader(modelID, c.GetHeader("anthropic-beta")))
|
||||||
|
} else if s.cfg != nil && s.cfg.Gateway.InjectBetaForApiKey && req.Header.Get("anthropic-beta") == "" {
|
||||||
|
// API-key:与 messages 同步的按需 beta 注入(默认关闭)
|
||||||
|
if requestNeedsBetaFeatures(body) {
|
||||||
|
if beta := defaultApiKeyBetaHeader(body); beta != "" {
|
||||||
|
req.Header.Set("anthropic-beta", beta)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return req, nil
|
return req, nil
|
||||||
|
|||||||
@@ -2278,11 +2278,13 @@ func convertClaudeToolsToGeminiTools(tools any) []any {
|
|||||||
"properties": map[string]any{},
|
"properties": map[string]any{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 清理 JSON Schema
|
||||||
|
cleanedParams := cleanToolSchema(params)
|
||||||
|
|
||||||
funcDecls = append(funcDecls, map[string]any{
|
funcDecls = append(funcDecls, map[string]any{
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": desc,
|
"description": desc,
|
||||||
"parameters": params,
|
"parameters": cleanedParams,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2296,6 +2298,41 @@ func convertClaudeToolsToGeminiTools(tools any) []any {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanToolSchema 清理工具的 JSON Schema,移除 Gemini 不支持的字段
|
||||||
|
func cleanToolSchema(schema any) any {
|
||||||
|
if schema == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := schema.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
cleaned := make(map[string]any)
|
||||||
|
for key, value := range v {
|
||||||
|
// 跳过不支持的字段
|
||||||
|
if key == "$schema" || key == "$id" || key == "$ref" ||
|
||||||
|
key == "additionalProperties" || key == "minLength" ||
|
||||||
|
key == "maxLength" || key == "minItems" || key == "maxItems" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 递归清理嵌套对象
|
||||||
|
cleaned[key] = cleanToolSchema(value)
|
||||||
|
}
|
||||||
|
// 规范化 type 字段为大写
|
||||||
|
if typeVal, ok := cleaned["type"].(string); ok {
|
||||||
|
cleaned["type"] = strings.ToUpper(typeVal)
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
case []any:
|
||||||
|
cleaned := make([]any, len(v))
|
||||||
|
for i, item := range v {
|
||||||
|
cleaned[i] = cleanToolSchema(item)
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func convertClaudeGenerationConfig(req map[string]any) map[string]any {
|
func convertClaudeGenerationConfig(req map[string]any) map[string]any {
|
||||||
out := make(map[string]any)
|
out := make(map[string]any)
|
||||||
if mt, ok := asInt(req["max_tokens"]); ok && mt > 0 {
|
if mt, ok := asInt(req["max_tokens"]); ok && mt > 0 {
|
||||||
|
|||||||
128
backend/internal/service/gemini_messages_compat_service_test.go
Normal file
128
backend/internal/service/gemini_messages_compat_service_test.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestConvertClaudeToolsToGeminiTools_CustomType 测试custom类型工具转换
|
||||||
|
func TestConvertClaudeToolsToGeminiTools_CustomType(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tools any
|
||||||
|
expectedLen int
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Standard tools",
|
||||||
|
tools: []any{
|
||||||
|
map[string]any{
|
||||||
|
"name": "get_weather",
|
||||||
|
"description": "Get weather info",
|
||||||
|
"input_schema": map[string]any{"type": "object"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedLen: 1,
|
||||||
|
description: "标准工具格式应该正常转换",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Custom type tool (MCP format)",
|
||||||
|
tools: []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "custom",
|
||||||
|
"name": "mcp_tool",
|
||||||
|
"custom": map[string]any{
|
||||||
|
"description": "MCP tool description",
|
||||||
|
"input_schema": map[string]any{"type": "object"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedLen: 1,
|
||||||
|
description: "Custom类型工具应该从custom字段读取",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed standard and custom tools",
|
||||||
|
tools: []any{
|
||||||
|
map[string]any{
|
||||||
|
"name": "standard_tool",
|
||||||
|
"description": "Standard",
|
||||||
|
"input_schema": map[string]any{"type": "object"},
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"type": "custom",
|
||||||
|
"name": "custom_tool",
|
||||||
|
"custom": map[string]any{
|
||||||
|
"description": "Custom",
|
||||||
|
"input_schema": map[string]any{"type": "object"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedLen: 1,
|
||||||
|
description: "混合工具应该都能正确转换",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Custom tool without custom field",
|
||||||
|
tools: []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "custom",
|
||||||
|
"name": "invalid_custom",
|
||||||
|
// 缺少 custom 字段
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedLen: 0, // 应该被跳过
|
||||||
|
description: "缺少custom字段的custom工具应该被跳过",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := convertClaudeToolsToGeminiTools(tt.tools)
|
||||||
|
|
||||||
|
if tt.expectedLen == 0 {
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("%s: expected nil result, got %v", tt.description, result)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
t.Fatalf("%s: expected non-nil result", tt.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Errorf("%s: expected 1 tool declaration, got %d", tt.description, len(result))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toolDecl, ok := result[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s: result[0] is not map[string]any", tt.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
funcDecls, ok := toolDecl["functionDeclarations"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s: functionDeclarations is not []any", tt.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
toolsArr, _ := tt.tools.([]any)
|
||||||
|
expectedFuncCount := 0
|
||||||
|
for _, tool := range toolsArr {
|
||||||
|
toolMap, _ := tool.(map[string]any)
|
||||||
|
if toolMap["name"] != "" {
|
||||||
|
// 检查是否为有效的custom工具
|
||||||
|
if toolMap["type"] == "custom" {
|
||||||
|
if toolMap["custom"] != nil {
|
||||||
|
expectedFuncCount++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expectedFuncCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(funcDecls) != expectedFuncCount {
|
||||||
|
t.Errorf("%s: expected %d function declarations, got %d",
|
||||||
|
tt.description, expectedFuncCount, len(funcDecls))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -163,6 +164,45 @@ type GeminiTokenInfo struct {
|
|||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
ProjectID string `json:"project_id,omitempty"`
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
OAuthType string `json:"oauth_type,omitempty"` // "code_assist" 或 "ai_studio"
|
OAuthType string `json:"oauth_type,omitempty"` // "code_assist" 或 "ai_studio"
|
||||||
|
TierID string `json:"tier_id,omitempty"` // Gemini Code Assist tier: LEGACY/PRO/ULTRA
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateTierID validates tier_id format and length
|
||||||
|
func validateTierID(tierID string) error {
|
||||||
|
if tierID == "" {
|
||||||
|
return nil // Empty is allowed
|
||||||
|
}
|
||||||
|
if len(tierID) > 64 {
|
||||||
|
return fmt.Errorf("tier_id exceeds maximum length of 64 characters")
|
||||||
|
}
|
||||||
|
// Allow alphanumeric, underscore, hyphen, and slash (for tier paths)
|
||||||
|
if !regexp.MustCompile(`^[a-zA-Z0-9_/-]+$`).MatchString(tierID) {
|
||||||
|
return fmt.Errorf("tier_id contains invalid characters")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTierIDFromAllowedTiers extracts tierID from LoadCodeAssist response
|
||||||
|
// Prioritizes IsDefault tier, falls back to first non-empty tier
|
||||||
|
func extractTierIDFromAllowedTiers(allowedTiers []geminicli.AllowedTier) string {
|
||||||
|
tierID := "LEGACY"
|
||||||
|
// First pass: look for default tier
|
||||||
|
for _, tier := range allowedTiers {
|
||||||
|
if tier.IsDefault && strings.TrimSpace(tier.ID) != "" {
|
||||||
|
tierID = strings.TrimSpace(tier.ID)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Second pass: if still LEGACY, take first non-empty tier
|
||||||
|
if tierID == "LEGACY" {
|
||||||
|
for _, tier := range allowedTiers {
|
||||||
|
if strings.TrimSpace(tier.ID) != "" {
|
||||||
|
tierID = strings.TrimSpace(tier.ID)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tierID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExchangeCodeInput) (*GeminiTokenInfo, error) {
|
func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExchangeCodeInput) (*GeminiTokenInfo, error) {
|
||||||
@@ -223,13 +263,14 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
|||||||
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
|
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
|
||||||
|
|
||||||
projectID := sessionProjectID
|
projectID := sessionProjectID
|
||||||
|
var tierID string
|
||||||
|
|
||||||
// 对于 code_assist 模式,project_id 是必需的
|
// 对于 code_assist 模式,project_id 是必需的
|
||||||
// 对于 ai_studio 模式,project_id 是可选的(不影响使用 AI Studio API)
|
// 对于 ai_studio 模式,project_id 是可选的(不影响使用 AI Studio API)
|
||||||
if oauthType == "code_assist" {
|
if oauthType == "code_assist" {
|
||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
var err error
|
var err error
|
||||||
projectID, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
|
projectID, tierID, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 记录警告但不阻断流程,允许后续补充 project_id
|
// 记录警告但不阻断流程,允许后续补充 project_id
|
||||||
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch project_id during token exchange: %v\n", err)
|
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch project_id during token exchange: %v\n", err)
|
||||||
@@ -248,6 +289,7 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
|
|||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
Scope: tokenResp.Scope,
|
Scope: tokenResp.Scope,
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
|
TierID: tierID,
|
||||||
OAuthType: oauthType,
|
OAuthType: oauthType,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -357,7 +399,7 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
|
|||||||
// For Code Assist, project_id is required. Auto-detect if missing.
|
// For Code Assist, project_id is required. Auto-detect if missing.
|
||||||
// For AI Studio OAuth, project_id is optional and should not block refresh.
|
// For AI Studio OAuth, project_id is optional and should not block refresh.
|
||||||
if oauthType == "code_assist" && strings.TrimSpace(tokenInfo.ProjectID) == "" {
|
if oauthType == "code_assist" && strings.TrimSpace(tokenInfo.ProjectID) == "" {
|
||||||
projectID, err := s.fetchProjectID(ctx, tokenInfo.AccessToken, proxyURL)
|
projectID, tierID, err := s.fetchProjectID(ctx, tokenInfo.AccessToken, proxyURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to auto-detect project_id: %w", err)
|
return nil, fmt.Errorf("failed to auto-detect project_id: %w", err)
|
||||||
}
|
}
|
||||||
@@ -366,6 +408,7 @@ func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *A
|
|||||||
return nil, fmt.Errorf("failed to auto-detect project_id: empty result")
|
return nil, fmt.Errorf("failed to auto-detect project_id: empty result")
|
||||||
}
|
}
|
||||||
tokenInfo.ProjectID = projectID
|
tokenInfo.ProjectID = projectID
|
||||||
|
tokenInfo.TierID = tierID
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokenInfo, nil
|
return tokenInfo, nil
|
||||||
@@ -388,6 +431,13 @@ func (s *GeminiOAuthService) BuildAccountCredentials(tokenInfo *GeminiTokenInfo)
|
|||||||
if tokenInfo.ProjectID != "" {
|
if tokenInfo.ProjectID != "" {
|
||||||
creds["project_id"] = tokenInfo.ProjectID
|
creds["project_id"] = tokenInfo.ProjectID
|
||||||
}
|
}
|
||||||
|
if tokenInfo.TierID != "" {
|
||||||
|
// Validate tier_id before storing
|
||||||
|
if err := validateTierID(tokenInfo.TierID); err == nil {
|
||||||
|
creds["tier_id"] = tokenInfo.TierID
|
||||||
|
}
|
||||||
|
// Silently skip invalid tier_id (don't block account creation)
|
||||||
|
}
|
||||||
if tokenInfo.OAuthType != "" {
|
if tokenInfo.OAuthType != "" {
|
||||||
creds["oauth_type"] = tokenInfo.OAuthType
|
creds["oauth_type"] = tokenInfo.OAuthType
|
||||||
}
|
}
|
||||||
@@ -398,34 +448,26 @@ func (s *GeminiOAuthService) Stop() {
|
|||||||
s.sessionStore.Stop()
|
s.sessionStore.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, proxyURL string) (string, error) {
|
func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, proxyURL string) (string, string, error) {
|
||||||
if s.codeAssist == nil {
|
if s.codeAssist == nil {
|
||||||
return "", errors.New("code assist client not configured")
|
return "", "", errors.New("code assist client not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
loadResp, loadErr := s.codeAssist.LoadCodeAssist(ctx, accessToken, proxyURL, nil)
|
loadResp, loadErr := s.codeAssist.LoadCodeAssist(ctx, accessToken, proxyURL, nil)
|
||||||
|
|
||||||
|
// Extract tierID from response (works whether CloudAICompanionProject is set or not)
|
||||||
|
tierID := "LEGACY"
|
||||||
|
if loadResp != nil {
|
||||||
|
tierID = extractTierIDFromAllowedTiers(loadResp.AllowedTiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If LoadCodeAssist returned a project, use it
|
||||||
if loadErr == nil && loadResp != nil && strings.TrimSpace(loadResp.CloudAICompanionProject) != "" {
|
if loadErr == nil && loadResp != nil && strings.TrimSpace(loadResp.CloudAICompanionProject) != "" {
|
||||||
return strings.TrimSpace(loadResp.CloudAICompanionProject), nil
|
return strings.TrimSpace(loadResp.CloudAICompanionProject), tierID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick tier from allowedTiers; if no default tier is marked, pick the first non-empty tier ID.
|
// Pick tier from allowedTiers; if no default tier is marked, pick the first non-empty tier ID.
|
||||||
tierID := "LEGACY"
|
// (tierID already extracted above, reuse it)
|
||||||
if loadResp != nil {
|
|
||||||
for _, tier := range loadResp.AllowedTiers {
|
|
||||||
if tier.IsDefault && strings.TrimSpace(tier.ID) != "" {
|
|
||||||
tierID = strings.TrimSpace(tier.ID)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(tierID) == "" || tierID == "LEGACY" {
|
|
||||||
for _, tier := range loadResp.AllowedTiers {
|
|
||||||
if strings.TrimSpace(tier.ID) != "" {
|
|
||||||
tierID = strings.TrimSpace(tier.ID)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &geminicli.OnboardUserRequest{
|
req := &geminicli.OnboardUserRequest{
|
||||||
TierID: tierID,
|
TierID: tierID,
|
||||||
@@ -443,39 +485,39 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
|
|||||||
// If Code Assist onboarding fails (e.g. INVALID_ARGUMENT), fallback to Cloud Resource Manager projects.
|
// If Code Assist onboarding fails (e.g. INVALID_ARGUMENT), fallback to Cloud Resource Manager projects.
|
||||||
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
|
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
|
||||||
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
||||||
return strings.TrimSpace(fallback), nil
|
return strings.TrimSpace(fallback), tierID, nil
|
||||||
}
|
}
|
||||||
return "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
if resp.Done {
|
if resp.Done {
|
||||||
if resp.Response != nil && resp.Response.CloudAICompanionProject != nil {
|
if resp.Response != nil && resp.Response.CloudAICompanionProject != nil {
|
||||||
switch v := resp.Response.CloudAICompanionProject.(type) {
|
switch v := resp.Response.CloudAICompanionProject.(type) {
|
||||||
case string:
|
case string:
|
||||||
return strings.TrimSpace(v), nil
|
return strings.TrimSpace(v), tierID, nil
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
if id, ok := v["id"].(string); ok {
|
if id, ok := v["id"].(string); ok {
|
||||||
return strings.TrimSpace(id), nil
|
return strings.TrimSpace(id), tierID, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
|
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
|
||||||
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
||||||
return strings.TrimSpace(fallback), nil
|
return strings.TrimSpace(fallback), tierID, nil
|
||||||
}
|
}
|
||||||
return "", errors.New("onboardUser completed but no project_id returned")
|
return "", "", errors.New("onboardUser completed but no project_id returned")
|
||||||
}
|
}
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
|
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
|
||||||
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
||||||
return strings.TrimSpace(fallback), nil
|
return strings.TrimSpace(fallback), tierID, nil
|
||||||
}
|
}
|
||||||
if loadErr != nil {
|
if loadErr != nil {
|
||||||
return "", fmt.Errorf("loadCodeAssist failed (%v) and onboardUser timeout after %d attempts", loadErr, maxAttempts)
|
return "", "", fmt.Errorf("loadCodeAssist failed (%v) and onboardUser timeout after %d attempts", loadErr, maxAttempts)
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("onboardUser timeout after %d attempts", maxAttempts)
|
return "", "", fmt.Errorf("onboardUser timeout after %d attempts", maxAttempts)
|
||||||
}
|
}
|
||||||
|
|
||||||
type googleCloudProject struct {
|
type googleCloudProject struct {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
detected, err := p.geminiOAuthService.fetchProjectID(ctx, accessToken, proxyURL)
|
detected, tierID, err := p.geminiOAuthService.fetchProjectID(ctx, accessToken, proxyURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[GeminiTokenProvider] Auto-detect project_id failed: %v, fallback to AI Studio API mode", err)
|
log.Printf("[GeminiTokenProvider] Auto-detect project_id failed: %v, fallback to AI Studio API mode", err)
|
||||||
return accessToken, nil
|
return accessToken, nil
|
||||||
@@ -123,6 +123,9 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
account.Credentials = make(map[string]any)
|
account.Credentials = make(map[string]any)
|
||||||
}
|
}
|
||||||
account.Credentials["project_id"] = detected
|
account.Credentials["project_id"] = detected
|
||||||
|
if tierID != "" {
|
||||||
|
account.Credentials["tier_id"] = tierID
|
||||||
|
}
|
||||||
_ = p.accountRepo.Update(ctx, account)
|
_ = p.accountRepo.Update(ctx, account)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,21 @@ pricing:
|
|||||||
# Hash check interval in minutes
|
# Hash check interval in minutes
|
||||||
hash_check_interval_minutes: 10
|
hash_check_interval_minutes: 10
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Gateway (Optional)
|
||||||
|
# =============================================================================
|
||||||
|
gateway:
|
||||||
|
# Wait time (in seconds) for upstream response headers (streaming body not affected)
|
||||||
|
response_header_timeout: 300
|
||||||
|
# Log upstream error response body summary (safe/truncated; does not log request content)
|
||||||
|
log_upstream_error_body: false
|
||||||
|
# Max bytes to log from upstream error body
|
||||||
|
log_upstream_error_body_max_bytes: 2048
|
||||||
|
# Auto inject anthropic-beta for API-key accounts when needed (default off)
|
||||||
|
inject_beta_for_apikey: false
|
||||||
|
# Allow failover on selected 400 errors (default off)
|
||||||
|
failover_on_400: false
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Gemini OAuth (Required for Gemini accounts)
|
# Gemini OAuth (Required for Gemini accounts)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -952,6 +952,7 @@
|
|||||||
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
|
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -1367,6 +1368,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -1443,6 +1445,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
|
"resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kurkle/color": "^0.3.0"
|
"@kurkle/color": "^0.3.0"
|
||||||
},
|
},
|
||||||
@@ -2040,6 +2043,7 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -2348,6 +2352,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -2821,6 +2826,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2854,6 +2860,7 @@
|
|||||||
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -2926,6 +2933,7 @@
|
|||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -3097,6 +3105,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz",
|
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz",
|
||||||
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
|
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.25",
|
"@vue/compiler-dom": "3.5.25",
|
||||||
"@vue/compiler-sfc": "3.5.25",
|
"@vue/compiler-sfc": "3.5.25",
|
||||||
@@ -3190,6 +3199,7 @@
|
|||||||
"integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
|
"integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@volar/typescript": "2.4.15",
|
"@volar/typescript": "2.4.15",
|
||||||
"@vue/language-core": "2.2.12"
|
"@vue/language-core": "2.2.12"
|
||||||
|
|||||||
@@ -83,6 +83,14 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tier Indicator -->
|
||||||
|
<span
|
||||||
|
v-if="tierDisplay"
|
||||||
|
class="inline-flex items-center rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{{ tierDisplay }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -140,4 +148,23 @@ const statusText = computed(() => {
|
|||||||
return props.account.status
|
return props.account.status
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Computed: tier display
|
||||||
|
const tierDisplay = computed(() => {
|
||||||
|
const credentials = props.account.credentials as Record<string, any> | undefined
|
||||||
|
const tierId = credentials?.tier_id
|
||||||
|
if (!tierId || tierId === 'unknown') return null
|
||||||
|
|
||||||
|
const tierMap: Record<string, string> = {
|
||||||
|
'free': 'Free',
|
||||||
|
'payg': 'Pay-as-you-go',
|
||||||
|
'pay-as-you-go': 'Pay-as-you-go',
|
||||||
|
'enterprise': 'Enterprise',
|
||||||
|
'LEGACY': 'Legacy',
|
||||||
|
'PRO': 'Pro',
|
||||||
|
'ULTRA': 'Ultra'
|
||||||
|
}
|
||||||
|
|
||||||
|
return tierMap[tierId] || tierId
|
||||||
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user