feat(gemini): Vertex 账号支持 API Key 认证

- Gemini 平台的 Vertex 账号新增 API Key 认证(credentials.auth_mode=apikey),
  不填 Project ID 走全局端点,填写后走项目级端点,Key 通过 x-goog-api-key 传递;
  Anthropic 平台的 Vertex 账号仍只支持 Service Account JSON
- 转发(Claude 兼容 / 原生 / Chat Completions)与测试连接统一走
  buildVertexGeminiAccountURL / setVertexGeminiAuth
- API Key 账号不参与显式缓存(Vertex 缓存接口不接受 API Key)与批量生图
- 创建弹窗新增认证方式选择;编辑弹窗支持轮换 Key 与修改 Project ID,
  Key 留空表示保留
This commit is contained in:
feeeei
2026-10-10 23:37:48 +08:00
parent 64331a9792
commit 81e6ec5346
14 changed files with 655 additions and 156 deletions
@@ -2585,14 +2585,7 @@ func (s *AccountTestService) buildGeminiOAuthRequest(ctx context.Context, accoun
}
func (s *AccountTestService) buildGeminiServiceAccountRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) {
if s.geminiTokenProvider == nil {
return nil, fmt.Errorf("gemini token provider not configured")
}
accessToken, err := s.geminiTokenProvider.GetAccessToken(ctx, account)
if err != nil {
return nil, fmt.Errorf("failed to get service account access token: %w", err)
}
fullURL, err := buildVertexGeminiURL(account.VertexProjectID(), account.VertexLocation(modelID), modelID, "streamGenerateContent", true)
fullURL, err := buildVertexGeminiAccountURL(account, modelID, "streamGenerateContent", true)
if err != nil {
return nil, err
}
@@ -2601,7 +2594,9 @@ func (s *AccountTestService) buildGeminiServiceAccountRequest(ctx context.Contex
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
if err := setVertexGeminiAuth(ctx, req, account, s.geminiTokenProvider); err != nil {
return nil, fmt.Errorf("failed to authenticate vertex request: %w", err)
}
return req, nil
}
@@ -112,13 +112,14 @@ func IsGeminiExplicitCacheAccountType(account *Account) bool {
}
// IsGeminiExplicitCacheAccount 判断完整账号是否支持显式缓存:官方地址的 AI Studio API Key 账号、
// 声明上游支持显式缓存的自定义地址 API Key 账号,或 Vertex service account 账号。
// 声明上游支持显式缓存的自定义地址 API Key 账号,或 Service Account JSON 认证的 Vertex 账号。
// API Key 认证的 Vertex 账号不参与显式缓存:Vertex 缓存接口只接受 OAuth access token,不接受 API Key。
func IsGeminiExplicitCacheAccount(account *Account) bool {
if !IsGeminiExplicitCacheAccountType(account) {
return false
}
if account.Type == AccountTypeServiceAccount {
return true
return !account.IsVertexAPIKey()
}
if strings.TrimSpace(account.GetCredential("api_key")) == "" {
return false
@@ -186,6 +186,7 @@ func TestIsGeminiExplicitCacheAccount(t *testing.T) {
{"official host with path prefix", &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k", "base_url": "https://generativelanguage.googleapis.com/antigravity"}}, false},
{"missing api key", &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey}, false},
{"vertex service account", &Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount}, true},
{"vertex api key", &Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{"auth_mode": "apikey", "api_key": "k", "project_id": "my-project"}}, false},
{"oauth", &Account{Platform: PlatformGemini, Type: AccountTypeOAuth}, false},
{"antigravity api key", &Account{Platform: PlatformAntigravity, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k"}}, false},
}
@@ -399,19 +399,11 @@ func (s *GeminiMessagesCompatService) buildGeminiChatCompletionsUpstreamRequestF
case AccountTypeServiceAccount:
return func(ctx context.Context) (*http.Request, string, error) {
if s.tokenProvider == nil {
return nil, "", errors.New("gemini token provider not configured")
}
accessToken, err := s.tokenProvider.GetAccessToken(ctx, account)
if err != nil {
return nil, "", err
}
action := "generateContent"
if clientStream {
action = "streamGenerateContent"
}
fullURL, err := buildVertexGeminiURL(account.VertexProjectID(), account.VertexLocation(mappedModel), mappedModel, action, clientStream)
fullURL, err := buildVertexGeminiAccountURL(account, mappedModel, action, clientStream)
if err != nil {
return nil, "", err
}
@@ -422,7 +414,9 @@ func (s *GeminiMessagesCompatService) buildGeminiChatCompletionsUpstreamRequestF
return nil, "", err
}
upstreamReq.Header.Set("Content-Type", "application/json")
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
if err := setVertexGeminiAuth(ctx, upstreamReq, account, s.tokenProvider); err != nil {
return nil, "", err
}
return upstreamReq, "x-request-id", nil
}, "x-request-id"
@@ -770,19 +770,11 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
case AccountTypeServiceAccount:
buildReq = func(ctx context.Context) (*http.Request, string, error) {
if s.tokenProvider == nil {
return nil, "", errors.New("gemini token provider not configured")
}
accessToken, err := s.tokenProvider.GetAccessToken(ctx, account)
if err != nil {
return nil, "", err
}
action := "generateContent"
if req.Stream {
action = "streamGenerateContent"
}
fullURL, err := buildVertexGeminiURL(account.VertexProjectID(), account.VertexLocation(mappedModel), mappedModel, action, req.Stream)
fullURL, err := buildVertexGeminiAccountURL(account, mappedModel, action, req.Stream)
if err != nil {
return nil, "", err
}
@@ -793,7 +785,9 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
return nil, "", err
}
upstreamReq.Header.Set("Content-Type", "application/json")
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
if err := setVertexGeminiAuth(ctx, upstreamReq, account, s.tokenProvider); err != nil {
return nil, "", err
}
return upstreamReq, "x-request-id", nil
}
requestIDHeader = "x-request-id"
@@ -1311,15 +1305,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
case AccountTypeServiceAccount:
buildReq = func(ctx context.Context) (*http.Request, string, error) {
if s.tokenProvider == nil {
return nil, "", errors.New("gemini token provider not configured")
}
accessToken, err := s.tokenProvider.GetAccessToken(ctx, account)
if err != nil {
return nil, "", err
}
fullURL, err := buildVertexGeminiURL(account.VertexProjectID(), account.VertexLocation(mappedModel), mappedModel, upstreamAction, useUpstreamStream)
fullURL, err := buildVertexGeminiAccountURL(account, mappedModel, upstreamAction, useUpstreamStream)
if err != nil {
return nil, "", err
}
@@ -1329,7 +1315,9 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
return nil, "", err
}
upstreamReq.Header.Set("Content-Type", "application/json")
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
if err := setVertexGeminiAuth(ctx, upstreamReq, account, s.tokenProvider); err != nil {
return nil, "", err
}
return upstreamReq, "x-request-id", nil
}
requestIDHeader = "x-request-id"
@@ -0,0 +1,101 @@
//go:build unit
package service
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
const geminiVertexAPIKeyTestResponse = `{"candidates":[{"content":{"parts":[{"text":"hello"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}`
func newGeminiVertexAPIKeyTestService() (*GeminiMessagesCompatService, *geminiCompatHTTPUpstreamStub) {
httpStub := &geminiCompatHTTPUpstreamStub{
response: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}, "X-Request-Id": []string{"vertex-req-1"}},
Body: io.NopCloser(strings.NewReader(geminiVertexAPIKeyTestResponse)),
},
}
// tokenProvider 留空:API Key 认证不依赖 access token。
return &GeminiMessagesCompatService{httpUpstream: httpStub, cfg: &config.Config{}}, httpStub
}
func geminiVertexAPIKeyTestAccount(projectID string) *Account {
credentials := map[string]any{
"auth_mode": "apikey",
"api_key": "vertex-test-key",
"location": "us-central1",
"tier_id": "vertex",
}
if projectID != "" {
credentials["project_id"] = projectID
}
return &Account{
ID: 811,
Platform: PlatformGemini,
Type: AccountTypeServiceAccount,
Credentials: credentials,
Concurrency: 1,
}
}
func requireVertexAPIKeyAuth(t *testing.T, req *http.Request) {
t.Helper()
require.NotNil(t, req)
require.Equal(t, "vertex-test-key", req.Header.Get("x-goog-api-key"))
require.Empty(t, req.Header.Get("Authorization"))
}
func TestGeminiForwardNative_VertexAPIKeyWithoutProjectUsesExpressEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
svc, httpStub := newGeminiVertexAPIKeyTestService()
c, _ := newGeminiNativeTestContext(t)
result, err := svc.ForwardNative(context.Background(), c, geminiVertexAPIKeyTestAccount(""),
"gemini-3-flash-preview", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`))
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 1, httpStub.calls)
require.Equal(t, "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent", httpStub.lastReq.URL.String())
requireVertexAPIKeyAuth(t, httpStub.lastReq)
}
func TestGeminiMessagesCompatServiceForward_VertexAPIKeyWithProjectUsesProjectEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
svc, httpStub := newGeminiVertexAPIKeyTestService()
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
body := []byte(`{"model":"gemini-3-flash-preview","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`)
result, err := svc.Forward(context.Background(), c, geminiVertexAPIKeyTestAccount("my-project"), body)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-3-flash-preview:generateContent", httpStub.lastReq.URL.String())
requireVertexAPIKeyAuth(t, httpStub.lastReq)
}
func TestGeminiForwardAsChatCompletions_VertexAPIKeyUsesAPIKeyHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
svc, httpStub := newGeminiVertexAPIKeyTestService()
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := []byte(`{"model":"gemini-3-flash-preview","messages":[{"role":"user","content":"hi"}]}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiVertexAPIKeyTestAccount(""), body)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent", httpStub.lastReq.URL.String())
requireVertexAPIKeyAuth(t, httpStub.lastReq)
}
@@ -29,6 +29,8 @@ const (
vertexServiceAccountCacheSkew = 5 * time.Minute
vertexLockWaitTime = 200 * time.Millisecond
vertexAnthropicVersion = "vertex-2023-10-16"
vertexGlobalHost = "aiplatform.googleapis.com"
vertexAuthModeAPIKey = "apikey"
)
var (
@@ -58,6 +60,13 @@ func (a *Account) IsVertexServiceAccount() bool {
return a != nil && a.Type == AccountTypeServiceAccount
}
// IsVertexAPIKey 判断 Gemini 平台的 Vertex 账号是否使用 API Key 认证(credentials.auth_mode=apikey)。
// Anthropic 平台的 Vertex 账号只支持 Service Account JSON。
func (a *Account) IsVertexAPIKey() bool {
return a.IsVertexServiceAccount() && a.Platform == PlatformGemini &&
strings.TrimSpace(a.GetCredential("auth_mode")) == vertexAuthModeAPIKey
}
func (a *Account) VertexProjectID() string {
if a == nil {
return ""
@@ -283,6 +292,63 @@ func exchangeVertexServiceAccountToken(ctx context.Context, key *vertexServiceAc
return parsed.AccessToken, ttl, nil
}
// buildVertexGeminiAccountURL 按账号构造 Vertex Gemini 请求地址:未配置 project_id 的 API Key 账号
// 走 Express mode 全局端点,其余账号走项目级端点。
func buildVertexGeminiAccountURL(account *Account, model, action string, stream bool) (string, error) {
if account.IsVertexAPIKey() && account.VertexProjectID() == "" {
return buildVertexGeminiExpressURL(model, action, stream)
}
return buildVertexGeminiURL(account.VertexProjectID(), account.VertexLocation(model), model, action, stream)
}
// buildVertexGeminiExpressURL 构造不带项目与区域的全局端点地址,仅 API Key 认证可用。
func buildVertexGeminiExpressURL(model, action string, stream bool) (string, error) {
model = strings.TrimSpace(model)
action = strings.TrimSpace(action)
if model == "" {
return "", errors.New("vertex model is required")
}
if err := validateVertexGeminiAction(action); err != nil {
return "", err
}
u := fmt.Sprintf("https://%s/v1/publishers/google/models/%s:%s", vertexGlobalHost, url.PathEscape(model), action)
if stream {
u += "?alt=sse"
}
return u, nil
}
func validateVertexGeminiAction(action string) error {
switch action {
case "generateContent", "streamGenerateContent", "countTokens":
return nil
default:
return fmt.Errorf("unsupported vertex gemini action: %s", action)
}
}
// setVertexGeminiAuth 给 Vertex Gemini 请求设置认证头:API Key 账号用 x-goog-api-key,
// Service Account JSON 账号用换取的 Bearer access token。
func setVertexGeminiAuth(ctx context.Context, req *http.Request, account *Account, tokenProvider *GeminiTokenProvider) error {
if account.IsVertexAPIKey() {
apiKey := strings.TrimSpace(account.GetCredential("api_key"))
if apiKey == "" {
return errors.New("vertex api_key not configured")
}
req.Header.Set("x-goog-api-key", apiKey)
return nil
}
if tokenProvider == nil {
return errors.New("gemini token provider not configured")
}
accessToken, err := tokenProvider.GetAccessToken(ctx, account)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
return nil
}
func buildVertexGeminiURL(projectID, location, model, action string, stream bool) (string, error) {
projectID = strings.TrimSpace(projectID)
location = strings.TrimSpace(location)
@@ -300,14 +366,12 @@ func buildVertexGeminiURL(projectID, location, model, action string, stream bool
if model == "" {
return "", errors.New("vertex model is required")
}
switch action {
case "generateContent", "streamGenerateContent", "countTokens":
default:
return "", fmt.Errorf("unsupported vertex gemini action: %s", action)
if err := validateVertexGeminiAction(action); err != nil {
return "", err
}
host := fmt.Sprintf("%s-aiplatform.googleapis.com", location)
if location == "global" {
host = "aiplatform.googleapis.com"
host = vertexGlobalHost
}
u := fmt.Sprintf(
"https://%s/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
@@ -30,6 +30,62 @@ func TestBuildVertexGeminiURLUsesGlobalEndpointHost(t *testing.T) {
require.Equal(t, "https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/google/models/gemini-3-flash-preview:streamGenerateContent?alt=sse", got)
}
func TestIsVertexAPIKey(t *testing.T) {
apiKeyCreds := map[string]any{"auth_mode": "apikey", "api_key": "k"}
require.True(t, (&Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: apiKeyCreds}).IsVertexAPIKey())
require.False(t, (&Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{"service_account_json": "{}"}}).IsVertexAPIKey())
require.False(t, (&Account{Platform: PlatformAnthropic, Type: AccountTypeServiceAccount, Credentials: apiKeyCreds}).IsVertexAPIKey())
require.False(t, (&Account{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: apiKeyCreds}).IsVertexAPIKey())
}
func TestBuildVertexGeminiAccountURL(t *testing.T) {
cases := []struct {
name string
credentials map[string]any
want string
}{
{
name: "api key without project uses express global endpoint",
credentials: map[string]any{"auth_mode": "apikey", "api_key": "k", "location": "us-central1"},
want: "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:streamGenerateContent?alt=sse",
},
{
name: "api key with project uses project endpoint",
credentials: map[string]any{"auth_mode": "apikey", "api_key": "k", "project_id": "my-project", "location": "us-central1"},
want: "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-3-flash-preview:streamGenerateContent?alt=sse",
},
{
name: "service account uses project endpoint",
credentials: map[string]any{"project_id": "sa-project", "location": "global"},
want: "https://aiplatform.googleapis.com/v1/projects/sa-project/locations/global/publishers/google/models/gemini-3-flash-preview:streamGenerateContent?alt=sse",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
account := &Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: tc.credentials}
got, err := buildVertexGeminiAccountURL(account, "gemini-3-flash-preview", "streamGenerateContent", true)
require.NoError(t, err)
require.Equal(t, tc.want, got)
})
}
account := &Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{"auth_mode": "apikey", "api_key": "k"}}
_, err := buildVertexGeminiAccountURL(account, "gemini-3-flash-preview", "rawPredict", false)
require.ErrorContains(t, err, "unsupported vertex gemini action")
}
func TestSetVertexGeminiAuthUsesAPIKeyHeader(t *testing.T) {
account := &Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{"auth_mode": "apikey", "api_key": " vertex-key "}}
req := httptest.NewRequest(http.MethodPost, "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent", nil)
require.NoError(t, setVertexGeminiAuth(context.Background(), req, account, nil))
require.Equal(t, "vertex-key", req.Header.Get("x-goog-api-key"))
require.Empty(t, req.Header.Get("Authorization"))
account.Credentials["api_key"] = ""
require.ErrorContains(t, setVertexGeminiAuth(context.Background(), req, account, nil), "vertex api_key not configured")
}
func TestBuildVertexAnthropicURL(t *testing.T) {
got, err := buildVertexAnthropicURL("my-project", "us-east5", "claude-sonnet-4-5@20250929", false)
require.NoError(t, err)
@@ -833,7 +833,7 @@
{{ t('admin.accounts.vertexLabel') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.vertexDesc') }}
{{ t('admin.accounts.vertexGeminiDesc') }}
</span>
</div>
</button>
@@ -1215,92 +1215,169 @@
<!-- Vertex Service Account -->
<div v-if="(form.platform === 'gemini' || form.platform === 'anthropic') && accountCategory === 'service_account'" class="space-y-4">
<div>
<label class="input-label">Service Account JSON</label>
<input
ref="vertexServiceAccountFileInput"
type="file"
accept="application/json,.json"
class="hidden"
@change="handleVertexServiceAccountFile"
/>
<div
:class="[
'rounded-lg border-2 border-dashed px-4 py-5 transition-colors',
vertexServiceAccountDragActive
? 'border-sky-500 bg-sky-50 dark:border-sky-500 dark:bg-sky-900/20'
: 'border-gray-300 bg-gray-50 hover:border-sky-400 hover:bg-sky-50/60 dark:border-dark-500 dark:bg-dark-700/40 dark:hover:border-sky-600 dark:hover:bg-sky-900/10'
]"
@dragenter.prevent="vertexServiceAccountDragActive = true"
@dragover.prevent="vertexServiceAccountDragActive = true"
@dragleave.prevent="vertexServiceAccountDragActive = false"
@drop.prevent="handleVertexServiceAccountDrop"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Icon name="upload" size="sm" />
<span>{{ vertexClientEmail ? t('admin.accounts.vertexSaJsonLoaded') : t('admin.accounts.vertexSaJsonDrop') }}</span>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ vertexClientEmail ? t('admin.accounts.vertexSaJsonKeyHidden') : t('admin.accounts.vertexSaJsonDropHint') }}
</p>
</div>
<button
type="button"
class="btn btn-secondary shrink-0"
@click="vertexServiceAccountFileInput?.click()"
>
<Icon name="upload" size="sm" />
{{ t('admin.accounts.vertexSaJsonSelectBtn') }}
</button>
</div>
<div
v-if="vertexClientEmail"
class="mt-3 rounded-md border border-sky-200 bg-white px-3 py-2 text-xs text-sky-900 dark:border-sky-800/50 dark:bg-dark-800 dark:text-sky-200"
>
<div class="truncate">Project ID: <span class="font-mono">{{ vertexProjectId }}</span></div>
<div class="truncate">Client Email: <span class="font-mono">{{ vertexClientEmail }}</span></div>
</div>
<div v-if="form.platform === 'gemini'">
<label class="input-label">{{ t('admin.accounts.vertexAuthMode') }}</label>
<div class="mt-2 flex gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="vertexAuthMode"
type="radio"
value="service_account"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.vertexAuthModeServiceAccount') }}</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="vertexAuthMode"
type="radio"
value="apikey"
data-testid="vertex-auth-mode-apikey"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.vertexAuthModeApiKey') }}</span>
</label>
</div>
<p class="input-hint">{{ t('admin.accounts.vertexSaJsonUploadHint') }}</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<template v-if="vertexUsesApiKey">
<div>
<label class="input-label">Project ID</label>
<label class="input-label">API Key</label>
<input
v-model="vertexProjectId"
type="text"
class="input font-mono"
readonly
:placeholder="t('admin.accounts.vertexProjectIdPlaceholder')"
/>
</div>
<div>
<label class="input-label">Location</label>
<select
v-model="vertexLocation"
v-model="vertexApiKey"
type="password"
required
class="input font-mono"
>
<optgroup
v-for="group in VERTEX_LOCATION_OPTIONS"
:key="group.label"
:label="group.label"
>
<option
v-for="option in group.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</optgroup>
</select>
<p class="input-hint">{{ t('admin.accounts.vertexLocationHint') }}</p>
data-testid="vertex-api-key-input"
/>
<p class="input-hint">{{ t('admin.accounts.vertexApiKeyHint') }}</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="input-label">Project ID</label>
<input
v-model="vertexProjectId"
type="text"
class="input font-mono"
data-testid="vertex-api-key-project-id-input"
:placeholder="t('admin.accounts.vertexApiKeyProjectIdPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.vertexApiKeyProjectIdHint') }}</p>
</div>
<div v-if="vertexProjectId.trim()">
<label class="input-label">Location</label>
<select
v-model="vertexLocation"
required
class="input font-mono"
>
<optgroup
v-for="group in VERTEX_LOCATION_OPTIONS"
:key="group.label"
:label="group.label"
>
<option
v-for="option in group.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</optgroup>
</select>
<p class="input-hint">{{ t('admin.accounts.vertexLocationHint') }}</p>
</div>
</div>
</template>
<template v-else>
<div>
<label class="input-label">Service Account JSON</label>
<input
ref="vertexServiceAccountFileInput"
type="file"
accept="application/json,.json"
class="hidden"
@change="handleVertexServiceAccountFile"
/>
<div
:class="[
'rounded-lg border-2 border-dashed px-4 py-5 transition-colors',
vertexServiceAccountDragActive
? 'border-sky-500 bg-sky-50 dark:border-sky-500 dark:bg-sky-900/20'
: 'border-gray-300 bg-gray-50 hover:border-sky-400 hover:bg-sky-50/60 dark:border-dark-500 dark:bg-dark-700/40 dark:hover:border-sky-600 dark:hover:bg-sky-900/10'
]"
@dragenter.prevent="vertexServiceAccountDragActive = true"
@dragover.prevent="vertexServiceAccountDragActive = true"
@dragleave.prevent="vertexServiceAccountDragActive = false"
@drop.prevent="handleVertexServiceAccountDrop"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Icon name="upload" size="sm" />
<span>{{ vertexClientEmail ? t('admin.accounts.vertexSaJsonLoaded') : t('admin.accounts.vertexSaJsonDrop') }}</span>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ vertexClientEmail ? t('admin.accounts.vertexSaJsonKeyHidden') : t('admin.accounts.vertexSaJsonDropHint') }}
</p>
</div>
<button
type="button"
class="btn btn-secondary shrink-0"
@click="vertexServiceAccountFileInput?.click()"
>
<Icon name="upload" size="sm" />
{{ t('admin.accounts.vertexSaJsonSelectBtn') }}
</button>
</div>
<div
v-if="vertexClientEmail"
class="mt-3 rounded-md border border-sky-200 bg-white px-3 py-2 text-xs text-sky-900 dark:border-sky-800/50 dark:bg-dark-800 dark:text-sky-200"
>
<div class="truncate">Project ID: <span class="font-mono">{{ vertexProjectId }}</span></div>
<div class="truncate">Client Email: <span class="font-mono">{{ vertexClientEmail }}</span></div>
</div>
</div>
<p class="input-hint">{{ t('admin.accounts.vertexSaJsonUploadHint') }}</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="input-label">Project ID</label>
<input
v-model="vertexProjectId"
type="text"
class="input font-mono"
readonly
:placeholder="t('admin.accounts.vertexProjectIdPlaceholder')"
/>
</div>
<div>
<label class="input-label">Location</label>
<select
v-model="vertexLocation"
required
class="input font-mono"
>
<optgroup
v-for="group in VERTEX_LOCATION_OPTIONS"
:key="group.label"
:label="group.label"
>
<option
v-for="option in group.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</optgroup>
</select>
<p class="input-hint">{{ t('admin.accounts.vertexLocationHint') }}</p>
</div>
</div>
</template>
</div>
<!-- Antigravity model restriction (applies to OAuth + Upstream) -->
@@ -4651,6 +4728,17 @@ const vertexProjectId = ref('')
const vertexClientEmail = ref('')
const vertexLocation = ref('global')
const vertexServiceAccountDragActive = ref(false)
const vertexAuthMode = ref<'service_account' | 'apikey'>('service_account')
const vertexApiKey = ref('')
// API Key 认证只对 Gemini 平台开放,Anthropic 平台的 Vertex 账号始终使用 Service Account JSON。
const vertexUsesApiKey = computed(() => form.platform === 'gemini' && vertexAuthMode.value === 'apikey')
// 两种认证方式的 Project ID 来源不同(JSON 读取 / 手填),切换时清空凭证字段避免串用。
watch(vertexAuthMode, () => {
vertexServiceAccountJson.value = ''
vertexProjectId.value = ''
vertexClientEmail.value = ''
vertexApiKey.value = ''
})
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
@@ -5055,6 +5143,8 @@ watch(
vertexProjectId.value = ''
vertexClientEmail.value = ''
vertexLocation.value = 'global'
vertexAuthMode.value = 'service_account'
vertexApiKey.value = ''
// Reset Anthropic/Antigravity-specific settings when switching to other platforms
if (newPlatform !== 'anthropic' && newPlatform !== 'antigravity') {
interceptWarmupRequests.value = false
@@ -5558,6 +5648,8 @@ const resetForm = () => {
vertexProjectId.value = ''
vertexClientEmail.value = ''
vertexLocation.value = 'global'
vertexAuthMode.value = 'service_account'
vertexApiKey.value = ''
tempUnschedEnabled.value = false
tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist'
@@ -5906,6 +5998,28 @@ const handleSubmit = async () => {
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
return
}
if (vertexUsesApiKey.value) {
if (!vertexApiKey.value.trim()) {
appStore.showError(t('admin.accounts.vertexApiKeyRequired'))
return
}
const projectId = vertexProjectId.value.trim()
if (projectId && !vertexLocation.value.trim()) {
appStore.showError(t('admin.accounts.vertexLocationRequired'))
return
}
const credentials: Record<string, unknown> = {
auth_mode: 'apikey',
api_key: vertexApiKey.value.trim(),
location: vertexLocation.value.trim() || 'global',
tier_id: 'vertex'
}
if (projectId) {
credentials.project_id = projectId
}
await createAccountAndFinish(form.platform, 'service_account' as AccountType, credentials)
return
}
if (!parseVertexServiceAccountJson()) {
return
}
@@ -957,8 +957,30 @@
<!-- Vertex Service Account -->
<div v-if="(account.platform === 'gemini' || account.platform === 'anthropic') && account.type === 'service_account'" class="space-y-4">
<div v-if="isVertexAPIKeyMode">
<label class="input-label">API Key</label>
<input
v-model="editVertexApiKey"
type="password"
class="input font-mono"
data-testid="edit-vertex-api-key-input"
:placeholder="t('admin.accounts.leaveEmptyToKeep')"
/>
<p class="input-hint">{{ t('admin.accounts.vertexApiKeyHint') }}</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<div v-if="isVertexAPIKeyMode">
<label class="input-label">Project ID</label>
<input
v-model="editVertexProjectId"
type="text"
class="input font-mono"
data-testid="edit-vertex-api-key-project-id-input"
:placeholder="t('admin.accounts.vertexApiKeyProjectIdPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.vertexApiKeyProjectIdHint') }}</p>
</div>
<div v-else>
<label class="input-label">Project ID</label>
<input
v-model="editVertexProjectId"
@@ -969,7 +991,7 @@
/>
<p class="input-hint">{{ t('admin.accounts.vertexSaJsonEditHint') }}</p>
</div>
<div>
<div v-if="!isVertexAPIKeyMode || editVertexProjectId.trim()">
<label class="input-label">Location</label>
<select
v-model="editVertexLocation"
@@ -3595,6 +3617,12 @@ const editBedrockApiKeyValue = ref('')
const editVertexProjectId = ref('')
const editVertexClientEmail = ref('')
const editVertexLocation = ref('us-central1')
const editVertexApiKey = ref('')
const isVertexAPIKeyMode = computed(() =>
props.account?.platform === 'gemini' &&
props.account?.type === 'service_account' &&
(props.account?.credentials as Record<string, unknown>)?.auth_mode === 'apikey'
)
const isBedrockAPIKeyMode = computed(() =>
props.account?.type === 'bedrock' &&
(props.account?.credentials as Record<string, unknown>)?.auth_mode === 'apikey'
@@ -4228,6 +4256,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editVertexProjectId.value = ''
editVertexClientEmail.value = ''
editVertexLocation.value = 'us-central1'
editVertexApiKey.value = ''
antigravityProjectId.value =
newAccount.platform === 'antigravity' &&
newAccount.type === 'oauth' &&
@@ -4600,6 +4629,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editVertexProjectId.value = (credentials.project_id as string) || ''
editVertexClientEmail.value = (credentials.client_email as string) || ''
editVertexLocation.value = (credentials.location as string) || (credentials.vertex_location as string) || 'us-central1'
editVertexApiKey.value = ''
// Load model mappings for service_account
loadModelRestrictionFromMapping(credentials.model_mapping as Record<string, unknown> | undefined)
@@ -5411,35 +5441,61 @@ const handleSubmit = async () => {
} else if ((props.account.platform === 'gemini' || props.account.platform === 'anthropic') && props.account.type === 'service_account') {
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
const newCredentials: Record<string, unknown> = { ...currentCredentials }
if (!editVertexProjectId.value.trim()) {
appStore.showError(t('admin.accounts.vertexSaJsonMissingProjectId'))
return
}
if (!editVertexClientEmail.value.trim()) {
appStore.showError(t('admin.accounts.vertexSaJsonMissingClientEmail'))
return
}
if (!editVertexLocation.value.trim()) {
appStore.showError(t('admin.accounts.vertexLocationRequired'))
return
}
// SA JSON 已脱敏不再随 credentials 返回,存在性优先读 credentials_status。
// 若后端尚未升级(无 credentials_status),回退读旧结构 service_account_json / service_account。
const credentialsStatus = props.account.credentials_status
const hasExistingServiceAccountJson = credentialsStatus
? Boolean(
credentialsStatus.has_service_account_json || credentialsStatus.has_service_account
)
: Boolean(currentCredentials.service_account_json || currentCredentials.service_account)
if (!hasExistingServiceAccountJson) {
appStore.showError(t('admin.accounts.vertexSaJsonRequired'))
return
if (isVertexAPIKeyMode.value) {
// API Key 已脱敏不再随 credentials 返回,存在性读 credentials_status;留空表示保留原 Key。
const newApiKey = editVertexApiKey.value.trim()
const hasExistingApiKey = credentialsStatus
? Boolean(credentialsStatus.has_api_key)
: Boolean(currentCredentials.api_key)
if (!newApiKey && !hasExistingApiKey) {
appStore.showError(t('admin.accounts.vertexApiKeyRequired'))
return
}
const projectId = editVertexProjectId.value.trim()
if (projectId && !editVertexLocation.value.trim()) {
appStore.showError(t('admin.accounts.vertexLocationRequired'))
return
}
if (newApiKey) {
newCredentials.api_key = newApiKey
}
if (projectId) {
newCredentials.project_id = projectId
} else {
delete newCredentials.project_id
}
newCredentials.location = editVertexLocation.value.trim() || 'global'
} else {
if (!editVertexProjectId.value.trim()) {
appStore.showError(t('admin.accounts.vertexSaJsonMissingProjectId'))
return
}
if (!editVertexClientEmail.value.trim()) {
appStore.showError(t('admin.accounts.vertexSaJsonMissingClientEmail'))
return
}
if (!editVertexLocation.value.trim()) {
appStore.showError(t('admin.accounts.vertexLocationRequired'))
return
}
// SA JSON 已脱敏不再随 credentials 返回,存在性优先读 credentials_status。
// 若后端尚未升级(无 credentials_status),回退读旧结构 service_account_json / service_account。
const hasExistingServiceAccountJson = credentialsStatus
? Boolean(
credentialsStatus.has_service_account_json || credentialsStatus.has_service_account
)
: Boolean(currentCredentials.service_account_json || currentCredentials.service_account)
if (!hasExistingServiceAccountJson) {
appStore.showError(t('admin.accounts.vertexSaJsonRequired'))
return
}
newCredentials.project_id = editVertexProjectId.value.trim()
newCredentials.client_email = editVertexClientEmail.value.trim()
newCredentials.location = editVertexLocation.value.trim()
}
newCredentials.project_id = editVertexProjectId.value.trim()
newCredentials.client_email = editVertexClientEmail.value.trim()
newCredentials.location = editVertexLocation.value.trim()
newCredentials.tier_id = 'vertex'
// Add model mapping if configured
@@ -932,3 +932,60 @@ describe('CreateAccountModal gemini explicit cache upstream switch', () => {
})
})
})
describe('CreateAccountModal Vertex API key auth', () => {
beforeEach(() => {
authIsSimpleMode.value = true
createAccountMock.mockReset().mockResolvedValue({ id: 44, platform: 'gemini', type: 'service_account' })
probeUpstreamBillingMock.mockReset().mockResolvedValue({})
syncUpstreamModelsMock.mockReset().mockResolvedValue({ models: [], metadata: {} })
})
async function openGeminiVertexApiKeyForm() {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'Gemini')
await selectButtonByText(wrapper, 'admin.accounts.vertexLabel')
await wrapper.get('[data-testid="vertex-auth-mode-apikey"]').setValue(true)
await wrapper.get('form#create-account-form input[type="text"]').setValue('vertex api key account')
await wrapper.get('[data-testid="vertex-api-key-input"]').setValue(' vertex-key ')
return wrapper
}
it('creates a Gemini Vertex account with API key auth and no project for the express endpoint', async () => {
const wrapper = await openGeminiVertexApiKeyForm()
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
const payload = createAccountMock.mock.calls[0]?.[0]
expect(payload?.platform).toBe('gemini')
expect(payload?.type).toBe('service_account')
expect(payload?.credentials).toEqual({
auth_mode: 'apikey',
api_key: 'vertex-key',
location: 'global',
tier_id: 'vertex'
})
})
it('sends the project id when an API key account targets a project endpoint', async () => {
const wrapper = await openGeminiVertexApiKeyForm()
await wrapper.get('[data-testid="vertex-api-key-project-id-input"]').setValue(' my-project ')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
auth_mode: 'apikey',
api_key: 'vertex-key',
project_id: 'my-project',
location: 'global'
})
})
it('keeps Anthropic Vertex accounts on Service Account JSON only', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'admin.accounts.vertexLabel')
expect(wrapper.find('[data-testid="vertex-auth-mode-apikey"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="vertex-api-key-input"]').exists()).toBe(false)
})
})
@@ -1735,6 +1735,62 @@ describe('EditAccountModal', () => {
expect(updateAccountMock).not.toHaveBeenCalled()
})
it('keeps the redacted Vertex API key when saving without entering a new one', async () => {
const account = buildVertexAccount()
account.credentials = {
auth_mode: 'apikey',
project_id: 'demo-project',
location: 'us-central1',
tier_id: 'vertex'
}
account.credentials_status = { has_api_key: true }
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
expect(wrapper.find('[data-testid="edit-vertex-api-key-input"]').exists()).toBe(true)
await wrapper.get('[data-testid="edit-vertex-api-key-project-id-input"]').setValue('')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
const credentials = updateAccountMock.mock.calls[0]?.[1]?.credentials
expect(credentials).toMatchObject({ auth_mode: 'apikey', location: 'us-central1', tier_id: 'vertex' })
expect(credentials).not.toHaveProperty('api_key')
expect(credentials).not.toHaveProperty('project_id')
})
it('submits a rotated Vertex API key', async () => {
const account = buildVertexAccount()
account.credentials = { auth_mode: 'apikey', location: 'global', tier_id: 'vertex' }
account.credentials_status = { has_api_key: true }
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('[data-testid="edit-vertex-api-key-input"]').setValue(' rotated-key ')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.api_key).toBe('rotated-key')
})
it('blocks Vertex API key save when no key exists and none is entered', async () => {
const account = buildVertexAccount()
account.credentials = { auth_mode: 'apikey', location: 'global', tier_id: 'vertex' }
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
const wrapper = mountModal(account)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).not.toHaveBeenCalled()
})
it('loads and submits Antigravity configured project fallback', async () => {
const account = buildAntigravityAccount('configured-project')
updateAccountMock.mockReset()
@@ -591,8 +591,16 @@ export default {
bedrockDesc: 'SigV4 / API Key',
vertexLabel: 'Agent Platform (Vertex)',
vertexDesc: 'Service Account',
vertexGeminiDesc: 'Service Account / API Key',
vertexAnthropicHint: 'Use a Google Cloud Service Account JSON to call Anthropic Claude via Agent Platform (Vertex). It is recommended to configure model mapping to map client Claude model names to Agent Platform (Vertex) model IDs.',
vertexGeminiHint: 'Use a Google Cloud Service Account JSON to call Gemini via Agent Platform (Vertex). It is recommended to place these accounts in a separate group to avoid mixing with AI Studio/Gemini OAuth on the same models.',
vertexGeminiHint: 'Call Gemini via Agent Platform (Vertex) with a Service Account JSON or an API key. It is recommended to place these accounts in a separate group to avoid mixing with AI Studio/Gemini OAuth on the same models.',
vertexAuthMode: 'Authentication',
vertexAuthModeServiceAccount: 'Service Account JSON',
vertexAuthModeApiKey: 'API Key',
vertexApiKeyHint: 'Create it on the Agent Platform API Keys page in the Google Cloud console. Google\'s explicit context caching API does not accept API keys, so these accounts do not serve explicit caching or batch image generation; implicit caching still applies.',
vertexApiKeyRequired: 'Please enter an API key',
vertexApiKeyProjectIdPlaceholder: 'Optional',
vertexApiKeyProjectIdHint: 'Leave empty to use the express mode global endpoint (location is ignored). For a standard API key bound to a service account, enter the Project ID to call the project endpoint with Project ID + Location.',
vertexSaJsonLabel: 'Service Account JSON',
vertexSaJsonLoaded: 'Service Account JSON loaded',
vertexSaJsonDrop: 'Drop Service Account JSON here',
@@ -709,8 +709,16 @@ export default {
bedrockDesc: 'SigV4 / API Key',
vertexLabel: 'Agent Platform (Vertex)',
vertexDesc: 'Service Account',
vertexGeminiDesc: 'Service Account / API Key',
vertexAnthropicHint: '使用 Google Cloud Service Account JSON 通过 Agent Platform (Vertex) 调用 Anthropic Claude。建议配置模型映射,将客户端 Claude 模型名映射到 Agent Platform (Vertex) 模型 ID。',
vertexGeminiHint: '使用 Google Cloud Service Account JSON 通过 Agent Platform (Vertex) 调用 Gemini。建议将此类账号放入独立分组,避免和 AI Studio/Gemini OAuth 同模型混调。',
vertexGeminiHint: '通过 Agent Platform (Vertex) 调用 Gemini,支持 Service Account JSON 或 API Key 认证。建议将此类账号放入独立分组,避免和 AI Studio/Gemini OAuth 同模型混调。',
vertexAuthMode: '认证方式',
vertexAuthModeServiceAccount: 'Service Account JSON',
vertexAuthModeApiKey: 'API Key',
vertexApiKeyHint: '在 Google Cloud 控制台 Agent Platform 的 API Keys 页面创建。Google 显式缓存接口不接受 API Key,此类账号不参与显式缓存与批量生图;隐式缓存正常生效。',
vertexApiKeyRequired: '请输入 API Key',
vertexApiKeyProjectIdPlaceholder: '可选',
vertexApiKeyProjectIdHint: '留空走 Express mode 全局端点(不区分 Location);绑定 Service Account 的标准 API Key 可填写 Project ID,按 Project ID + Location 访问。',
vertexSaJsonLabel: 'Service Account JSON',
vertexSaJsonLoaded: '已读取 Service Account JSON',
vertexSaJsonDrop: '拖入 Service Account JSON',