From 34c102045ac46de7b28d77530bc0e7eca120af95 Mon Sep 17 00:00:00 2001
From: IanShaw <131567472+IanShaw027@users.noreply.github.com>
Date: Thu, 1 Jan 2026 04:21:18 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20/v1/messages=20?=
=?UTF-8?q?=E9=97=B4=E6=AD=87=E6=80=A7=20400=20=E9=94=99=E8=AF=AF=20(#18)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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 函数
---
backend/internal/config/config.go | 15 ++
.../internal/pkg/antigravity/claude_types.go | 3 +
.../pkg/antigravity/request_transformer.go | 223 +++++++++++++-----
.../antigravity/request_transformer_test.go | 179 ++++++++++++++
backend/internal/pkg/claude/constants.go | 6 +
.../service/antigravity_gateway_service.go | 9 +
backend/internal/service/gateway_service.go | 138 +++++++++++
.../service/gemini_messages_compat_service.go | 39 ++-
.../gemini_messages_compat_service_test.go | 128 ++++++++++
.../internal/service/gemini_oauth_service.go | 104 +++++---
.../internal/service/gemini_token_provider.go | 5 +-
deploy/config.example.yaml | 15 ++
frontend/package-lock.json | 10 +
.../account/AccountStatusIndicator.vue | 27 +++
14 files changed, 815 insertions(+), 86 deletions(-)
create mode 100644 backend/internal/pkg/antigravity/request_transformer_test.go
create mode 100644 backend/internal/service/gemini_messages_compat_service_test.go
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index aeeddcb4..d3674932 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -119,6 +119,17 @@ type GatewayConfig struct {
// ConcurrencySlotTTLMinutes: 并发槽位过期时间(分钟)
// 应大于最长 LLM 请求时间,防止请求完成前槽位过期
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 {
@@ -313,6 +324,10 @@ func setDefaults() {
// Gateway
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.connection_pool_isolation", ConnectionPoolIsolationAccountProxy)
// HTTP 上游连接池配置(针对 5000+ 并发用户优化)
diff --git a/backend/internal/pkg/antigravity/claude_types.go b/backend/internal/pkg/antigravity/claude_types.go
index 01b805cd..34e6b1f4 100644
--- a/backend/internal/pkg/antigravity/claude_types.go
+++ b/backend/internal/pkg/antigravity/claude_types.go
@@ -54,6 +54,9 @@ type CustomToolSpec struct {
InputSchema map[string]any `json:"input_schema"`
}
+// ClaudeCustomToolSpec 兼容旧命名(MCP custom 工具规格)
+type ClaudeCustomToolSpec = CustomToolSpec
+
// SystemBlock system prompt 数组形式的元素
type SystemBlock struct {
Type string `json:"type"`
diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go
index e0b5b886..83b87a32 100644
--- a/backend/internal/pkg/antigravity/request_transformer.go
+++ b/backend/internal/pkg/antigravity/request_transformer.go
@@ -14,13 +14,16 @@ func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel st
// 用于存储 tool_use id -> name 映射
toolIDToName := make(map[string]string)
- // 检测是否启用 thinking
- isThinkingEnabled := claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled"
-
// 只有 Gemini 模型支持 dummy thought workaround
// Claude 模型通过 Vertex/Google API 需要有效的 thought signatures
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
contents, err := buildContents(claudeReq.Messages, toolIDToName, isThinkingEnabled, allowDummyThought)
if err != nil {
@@ -31,7 +34,15 @@ func TransformClaudeToGemini(claudeReq *ClaudeRequest, projectID, mappedModel st
systemInstruction := buildSystemInstruction(claudeReq.System, claudeReq.Model)
// 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
tools := buildTools(claudeReq.Tools)
@@ -148,8 +159,9 @@ func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isT
if !hasThoughtPart && len(parts) > 0 {
// 在开头添加 dummy thinking block
parts = append([]GeminiPart{{
- Text: "Thinking...",
- Thought: true,
+ Text: "Thinking...",
+ Thought: true,
+ ThoughtSignature: dummyThoughtSignature,
}}, parts...)
}
}
@@ -171,6 +183,34 @@ func buildContents(messages []ClaudeMessage, toolIDToName map[string]string, isT
// 参考: https://ai.google.dev/gemini-api/docs/thought-signatures
const dummyThoughtSignature = "skip_thought_signature_validator"
+// isValidThoughtSignature 验证 thought signature 是否有效
+// Claude API 要求 signature 必须是 base64 编码的字符串,长度至少 32 字节
+func isValidThoughtSignature(signature string) bool {
+ // 空字符串无效
+ if signature == "" {
+ return false
+ }
+
+ // 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
// allowDummyThought: 只有 Gemini 模型支持 dummy thought signature
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":
- part := GeminiPart{
- Text: block.Thinking,
- Thought: true,
- }
- // 保留原有 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")
+ if allowDummyThought {
+ // Gemini 模型可以使用 dummy signature
+ parts = append(parts, GeminiPart{
+ Text: block.Thinking,
+ Thought: true,
+ ThoughtSignature: dummyThoughtSignature,
+ })
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":
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,
},
}
- // 保留原有 signature,或对 Gemini 模型使用 dummy signature
- if block.Signature != "" {
- part.ThoughtSignature = block.Signature
- } else if allowDummyThought {
+ // 只有 Gemini 模型使用 dummy signature
+ // Claude 模型不设置 signature(避免验证问题)
+ if allowDummyThought {
part.ThoughtSignature = dummyThoughtSignature
}
parts = append(parts, part)
@@ -386,9 +433,9 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
// 普通工具
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")
continue
}
@@ -397,10 +444,18 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
var inputSchema map[string]any
// 检查是否为 custom 类型工具 (MCP)
- if tool.Type == "custom" && tool.Custom != nil {
- // Custom 格式: 从 custom 字段获取 description 和 input_schema
+ if tool.Type == "custom" {
+ 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
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 {
// 标准格式: 从顶层字段获取
description = tool.Description
@@ -409,7 +464,6 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
// 清理 JSON Schema
params := cleanJSONSchema(inputSchema)
-
// 为 nil schema 提供默认值
if params == nil {
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{
Name: tool.Name,
Description: description,
@@ -479,31 +538,64 @@ func cleanJSONSchema(schema map[string]any) map[string]any {
}
// 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{
- "$schema": true,
- "$id": true,
- "$ref": true,
- "additionalProperties": true,
- "minLength": true,
- "maxLength": true,
- "minItems": true,
- "maxItems": true,
- "uniqueItems": true,
- "minimum": true,
- "maximum": true,
- "exclusiveMinimum": true,
- "exclusiveMaximum": true,
- "pattern": true,
- "format": true,
- "default": true,
- "strict": true,
- "const": true,
- "examples": true,
- "deprecated": true,
- "readOnly": true,
- "writeOnly": true,
- "contentMediaType": true,
- "contentEncoding": true,
+ // 元 schema 字段
+ "$schema": true,
+ "$id": true,
+ "$ref": true,
+
+ // 字符串验证(Gemini 不支持)
+ "minLength": true,
+ "maxLength": true,
+ "pattern": true,
+
+ // 数字验证(Claude API 通过 Vertex AI 不支持这些字段)
+ "minimum": true,
+ "maximum": true,
+ "exclusiveMinimum": true,
+ "exclusiveMaximum": true,
+ "multipleOf": 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,
+ "const": true,
+ "examples": true,
+ "deprecated": true,
+ "readOnly": true,
+ "writeOnly": true,
+ "contentMediaType": true,
+ "contentEncoding": true,
+
+ // Claude 特有字段
+ "strict": true,
}
// cleanSchemaValue 递归清理 schema 值
@@ -523,6 +615,31 @@ func cleanSchemaValue(value any) any {
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)
}
diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go
new file mode 100644
index 00000000..56eebad0
--- /dev/null
+++ b/backend/internal/pkg/antigravity/request_transformer_test.go
@@ -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))
+ }
+ }
+ })
+ }
+}
diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go
index 97ad6c83..0db3ed4a 100644
--- a/backend/internal/pkg/claude/constants.go
+++ b/backend/internal/pkg/claude/constants.go
@@ -16,6 +16,12 @@ const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleav
// HaikuBetaHeader Haiku 模型使用的 anthropic-beta header(不需要 claude-code beta)
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 客户端默认请求头
var DefaultHeaders = map[string]string{
"User-Agent": "claude-cli/2.0.62 (external, cli)",
diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go
index ae2976f8..5b3bf565 100644
--- a/backend/internal/service/antigravity_gateway_service.go
+++ b/backend/internal/service/antigravity_gateway_service.go
@@ -358,6 +358,15 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
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 := "generateContent"
if claudeReq.Stream {
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index d542e9c2..5884602d 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -19,6 +19,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
+ "github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"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 {
+ // 可选:对部分 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)
}
@@ -786,6 +811,13 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
// 处理anthropic-beta header(OAuth账号需要特殊处理)
if tokenType == "oauth" {
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
@@ -838,6 +870,83 @@ func (s *GatewayService) getBetaHeader(modelID string, clientBetaHeader string)
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) {
body, _ := io.ReadAll(resp.Body)
@@ -850,6 +959,16 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res
switch resp.StatusCode {
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)
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
case 401:
@@ -1329,6 +1448,18 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
// 标记账号状态(429/529等)
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"
switch resp.StatusCode {
@@ -1409,6 +1540,13 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
// OAuth 账号:处理 anthropic-beta header
if tokenType == "oauth" {
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
diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go
index a0bf1b6a..b1877800 100644
--- a/backend/internal/service/gemini_messages_compat_service.go
+++ b/backend/internal/service/gemini_messages_compat_service.go
@@ -2278,11 +2278,13 @@ func convertClaudeToolsToGeminiTools(tools any) []any {
"properties": map[string]any{},
}
}
+ // 清理 JSON Schema
+ cleanedParams := cleanToolSchema(params)
funcDecls = append(funcDecls, map[string]any{
"name": name,
"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 {
out := make(map[string]any)
if mt, ok := asInt(req["max_tokens"]); ok && mt > 0 {
diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go
new file mode 100644
index 00000000..d49f2eb3
--- /dev/null
+++ b/backend/internal/service/gemini_messages_compat_service_test.go
@@ -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))
+ }
+ })
+ }
+}
diff --git a/backend/internal/service/gemini_oauth_service.go b/backend/internal/service/gemini_oauth_service.go
index e4bda5f8..221bd0f2 100644
--- a/backend/internal/service/gemini_oauth_service.go
+++ b/backend/internal/service/gemini_oauth_service.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
+ "regexp"
"strconv"
"strings"
"time"
@@ -163,6 +164,45 @@ type GeminiTokenInfo struct {
Scope string `json:"scope,omitempty"`
ProjectID string `json:"project_id,omitempty"`
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) {
@@ -223,13 +263,14 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
projectID := sessionProjectID
+ var tierID string
// 对于 code_assist 模式,project_id 是必需的
// 对于 ai_studio 模式,project_id 是可选的(不影响使用 AI Studio API)
if oauthType == "code_assist" {
if projectID == "" {
var err error
- projectID, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
+ projectID, tierID, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
if err != nil {
// 记录警告但不阻断流程,允许后续补充 project_id
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,
Scope: tokenResp.Scope,
ProjectID: projectID,
+ TierID: tierID,
OAuthType: oauthType,
}, 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 AI Studio OAuth, project_id is optional and should not block refresh.
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 {
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")
}
tokenInfo.ProjectID = projectID
+ tokenInfo.TierID = tierID
}
return tokenInfo, nil
@@ -388,6 +431,13 @@ func (s *GeminiOAuthService) BuildAccountCredentials(tokenInfo *GeminiTokenInfo)
if 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 != "" {
creds["oauth_type"] = tokenInfo.OAuthType
}
@@ -398,34 +448,26 @@ func (s *GeminiOAuthService) 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 {
- 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)
+
+ // 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) != "" {
- 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.
- tierID := "LEGACY"
- 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
- }
- }
- }
- }
+ // (tierID already extracted above, reuse it)
req := &geminicli.OnboardUserRequest{
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.
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
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.Response != nil && resp.Response.CloudAICompanionProject != nil {
switch v := resp.Response.CloudAICompanionProject.(type) {
case string:
- return strings.TrimSpace(v), nil
+ return strings.TrimSpace(v), tierID, nil
case map[string]any:
if id, ok := v["id"].(string); ok {
- return strings.TrimSpace(id), nil
+ return strings.TrimSpace(id), tierID, nil
}
}
}
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
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)
}
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
if fbErr == nil && strings.TrimSpace(fallback) != "" {
- return strings.TrimSpace(fallback), nil
+ return strings.TrimSpace(fallback), tierID, 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 {
diff --git a/backend/internal/service/gemini_token_provider.go b/backend/internal/service/gemini_token_provider.go
index 2195ec55..5f369de5 100644
--- a/backend/internal/service/gemini_token_provider.go
+++ b/backend/internal/service/gemini_token_provider.go
@@ -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 {
log.Printf("[GeminiTokenProvider] Auto-detect project_id failed: %v, fallback to AI Studio API mode", err)
return accessToken, nil
@@ -123,6 +123,9 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
account.Credentials = make(map[string]any)
}
account.Credentials["project_id"] = detected
+ if tierID != "" {
+ account.Credentials["tier_id"] = tierID
+ }
_ = p.accountRepo.Update(ctx, account)
}
}
diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml
index 5bd85d7d..5478d151 100644
--- a/deploy/config.example.yaml
+++ b/deploy/config.example.yaml
@@ -122,6 +122,21 @@ pricing:
# Hash check interval in minutes
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)
# =============================================================================
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 6563ee0c..1770a985 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -952,6 +952,7 @@
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1367,6 +1368,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -1443,6 +1445,7 @@
"resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -2040,6 +2043,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -2348,6 +2352,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -2821,6 +2826,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -2854,6 +2860,7 @@
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2926,6 +2933,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -3097,6 +3105,7 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",
@@ -3190,6 +3199,7 @@
"integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@volar/typescript": "2.4.15",
"@vue/language-core": "2.2.12"
diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue
index c1ca08fa..914678a5 100644
--- a/frontend/src/components/account/AccountStatusIndicator.vue
+++ b/frontend/src/components/account/AccountStatusIndicator.vue
@@ -83,6 +83,14 @@
>
+
+
+
+ {{ tierDisplay }}
+
@@ -140,4 +148,23 @@ const statusText = computed(() => {
return props.account.status
})
+// Computed: tier display
+const tierDisplay = computed(() => {
+ const credentials = props.account.credentials as Record | undefined
+ const tierId = credentials?.tier_id
+ if (!tierId || tierId === 'unknown') return null
+
+ const tierMap: Record = {
+ '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
+})
+