feat(gemini): 适配 AI Studio AQ. 格式 Key

- AI Studio 新建 Key 为 AQ.Ab 前缀的 auth key:logredact.RedactText 与
  渠道监控 sanitizeErrorMessage 增加 AQ.Ab 规则,长度按 40 位以上放宽以覆盖格式变体
- 创建 / 编辑账号的 Key 占位符改为 AQ.Ab...,提示文案说明新旧两种前缀
  与第三方上游 Key
This commit is contained in:
feeeei
2026-10-10 23:37:48 +08:00
parent 2588675ce7
commit 1c68f9996d
8 changed files with 43 additions and 5 deletions
@@ -636,6 +636,8 @@ var monitorAPIKeyPatterns = []struct {
{regexp.MustCompile(`xai-[A-Za-z0-9_-]{6,}`), "xai-***REDACTED***"},
// Gemini / Google API Key:固定前缀 + 35 位
{regexp.MustCompile(`AIza[A-Za-z0-9_-]{35}`), "AIza***REDACTED***"},
// Gemini auth key:AQ.Ab 前缀 + 48 位,长度放宽以覆盖格式变体
{regexp.MustCompile(`AQ\.Ab[A-Za-z0-9_-]{40,}`), "AQ.***REDACTED***"},
// JWT 三段式(Bearer 后常出现):eyJxxx.eyJxxx.signature
{regexp.MustCompile(`eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`), "eyJ***REDACTED.JWT***"},
}
@@ -643,7 +645,7 @@ var monitorAPIKeyPatterns = []struct {
// sanitizeErrorMessage 擦除错误/响应文本中可能泄露的 API key。
// 处理两类来源:
// 1. URL query 中的 ?key= / ?api_key= 等(Go *url.Error 会回填完整 URL)
// 2. 上游 HTTP body 文本里直接出现的 sk-* / xai-* / AIza* / JWT 等密钥碎片
// 2. 上游 HTTP body 文本里直接出现的 sk-* / xai-* / AIza* / AQ.* / JWT 等密钥碎片
//
// 注意:与 gemini_messages_compat_service.go 的 sanitizeUpstreamErrorMessage 关注点类似但参数集更广,
// 监控模块独立维护,避免互相耦合。
@@ -279,6 +279,17 @@ func TestRunCheckForModel_Grok_RedactsXAIKeyFromUpstreamBody(t *testing.T) {
}
}
func TestSanitizeErrorMessage_RedactsGeminiAuthKey(t *testing.T) {
authKey := "AQ." + "Ab" + strings.Repeat("Xy9_-z", 8)
got := sanitizeErrorMessage(`{"error":{"message":"API key not valid: ` + authKey + `"}}`)
if strings.Contains(got, authKey) {
t.Fatalf("Gemini auth key leaked: %q", got)
}
if !strings.Contains(got, "AQ.***REDACTED***") {
t.Fatalf("expected redaction marker, got %q", got)
}
}
func TestRunCheckForModel_OpenAIResponses_DefaultRequest(t *testing.T) {
h := &openAICaptureHandler{}
endpoint := setupFakeOpenAI(t, h)
@@ -42,6 +42,8 @@ type textRedactPatterns struct {
var (
reGOCSPX = regexp.MustCompile(`GOCSPX-[0-9A-Za-z_-]{24,}`)
reAIza = regexp.MustCompile(`AIza[0-9A-Za-z_-]{35}`)
// Gemini auth key:AQ.Ab 前缀 + 48 位,长度放宽以覆盖格式变体。
reGeminiAuthKey = regexp.MustCompile(`AQ\.Ab[0-9A-Za-z_-]{40,}`)
defaultTextRedactPatterns = compileTextRedactPatterns(nil)
extraTextPatternCache sync.Map // map[string]*textRedactPatterns
@@ -99,6 +101,7 @@ func RedactText(input string, extraKeys ...string) string {
out := input
out = reGOCSPX.ReplaceAllString(out, "GOCSPX-***")
out = reAIza.ReplaceAllString(out, "AIza***")
out = reGeminiAuthKey.ReplaceAllString(out, "AQ.***")
out = patterns.reJSONLike.ReplaceAllString(out, `$1***$3`)
out = patterns.reQueryLike.ReplaceAllString(out, `$1=***`)
out = patterns.rePlain.ReplaceAllString(out, `$1$2***`)
@@ -38,6 +38,28 @@ func TestRedactText_GOCSPX(t *testing.T) {
}
}
func TestRedactText_GeminiAPIKeys(t *testing.T) {
legacyKey := "AI" + "za" + strings.Repeat("Ab1_-", 7)
authKey := "AQ." + "Ab" + strings.Repeat("Xy9_-z", 8)
in := "upstream rejected " + legacyKey + " and " + authKey + " for model gemini-3.1-pro"
out := RedactText(in)
if strings.Contains(out, legacyKey) || strings.Contains(out, authKey) {
t.Fatalf("expected Gemini keys redacted, got %q", out)
}
for _, want := range []string{"AIza***", "AQ.***", "for model gemini-3.1-pro"} {
if !strings.Contains(out, want) {
t.Fatalf("expected %q in %q", want, out)
}
}
}
func TestRedactText_GeminiAuthKeyPrefixAloneUnchanged(t *testing.T) {
in := "key format AQ.Ab... is documented"
if out := RedactText(in); out != in {
t.Fatalf("expected short AQ. prefix unchanged, got %q", out)
}
}
func TestRedactText_ExtraKeyCacheUsesNormalizedSortedKey(t *testing.T) {
clearExtraTextPatternCache()
@@ -4253,7 +4253,7 @@ const apiKeyValuePlaceholder = computed(() => {
case 'openai':
return 'sk-proj-...'
case 'gemini':
return 'AIza...'
return 'AQ.Ab...'
case 'grok':
return 'xai-...'
case 'kimi':
@@ -239,7 +239,7 @@
account.platform === 'openai'
? 'sk-proj-...'
: account.platform === 'gemini'
? 'AIza...'
? 'AQ.Ab...'
: account.platform === 'antigravity'
? 'sk-...'
: account.platform === 'grok'
@@ -1422,7 +1422,7 @@ export default {
modelPassthroughDesc:
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
baseUrlHint: 'Leave default for official Gemini API',
apiKeyHint: 'Your Gemini API Key (starts with AIza)',
apiKeyHint: 'New AI Studio keys start with AQ. (legacy keys start with AIza); for a third-party upstream, use the key it provides',
tier: {
label: 'Account Tier',
hint: 'Tip: The system will try to auto-detect the tier first; if auto-detection is unavailable or fails, your selected tier is used as a fallback (simulated quota).',
@@ -1513,7 +1513,7 @@ export default {
modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。',
baseUrlHint: '留空使用官方 Gemini API',
apiKeyHint: '您的 Gemini API Key(以 AIza 开头)',
apiKeyHint: 'AI Studio 新建的 Key 以 AQ. 开头(旧版以 AIza 开头);第三方上游填写其提供的 Key',
tier: {
label: '账号等级',
hint: '提示:系统会优先尝试自动识别账号等级;若自动识别不可用或失败,则使用你选择的等级作为回退(本地模拟配额)。',