diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 5ae0859a8f..26060efc78 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -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 } diff --git a/backend/internal/service/gemini_cached_content.go b/backend/internal/service/gemini_cached_content.go index 7c3c6b14a6..0fe24249c3 100644 --- a/backend/internal/service/gemini_cached_content.go +++ b/backend/internal/service/gemini_cached_content.go @@ -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 diff --git a/backend/internal/service/gemini_cached_content_test.go b/backend/internal/service/gemini_cached_content_test.go index f53ad6c94d..0aec766105 100644 --- a/backend/internal/service/gemini_cached_content_test.go +++ b/backend/internal/service/gemini_cached_content_test.go @@ -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}, } diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index a98c94cf93..1ad2b487e8 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -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" diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index c3edbd2fd4..dec439ea8b 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -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" diff --git a/backend/internal/service/gemini_vertex_api_key_forward_test.go b/backend/internal/service/gemini_vertex_api_key_forward_test.go new file mode 100644 index 0000000000..bf8be6c1c0 --- /dev/null +++ b/backend/internal/service/gemini_vertex_api_key_forward_test.go @@ -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) +} diff --git a/backend/internal/service/vertex_service_account.go b/backend/internal/service/vertex_service_account.go index 7ccbeee43c..2a19a50e21 100644 --- a/backend/internal/service/vertex_service_account.go +++ b/backend/internal/service/vertex_service_account.go @@ -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", diff --git a/backend/internal/service/vertex_service_account_test.go b/backend/internal/service/vertex_service_account_test.go index 68c756eaa2..77cb6617c7 100644 --- a/backend/internal/service/vertex_service_account_test.go +++ b/backend/internal/service/vertex_service_account_test.go @@ -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) diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index e4aa3dfc4c..5ea21bbb87 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -833,7 +833,7 @@ {{ t('admin.accounts.vertexLabel') }} - {{ t('admin.accounts.vertexDesc') }} + {{ t('admin.accounts.vertexGeminiDesc') }} @@ -1215,92 +1215,169 @@
- {{ vertexClientEmail ? t('admin.accounts.vertexSaJsonKeyHidden') : t('admin.accounts.vertexSaJsonDropHint') }} -
-{{ t('admin.accounts.vertexSaJsonUploadHint') }}
{{ t('admin.accounts.vertexLocationHint') }}
+ data-testid="vertex-api-key-input" + /> +{{ t('admin.accounts.vertexApiKeyHint') }}
{{ t('admin.accounts.vertexApiKeyProjectIdHint') }}
+{{ t('admin.accounts.vertexLocationHint') }}
++ {{ vertexClientEmail ? t('admin.accounts.vertexSaJsonKeyHidden') : t('admin.accounts.vertexSaJsonDropHint') }} +
+{{ t('admin.accounts.vertexSaJsonUploadHint') }}
+{{ t('admin.accounts.vertexLocationHint') }}
+{{ t('admin.accounts.vertexApiKeyHint') }}
+{{ t('admin.accounts.vertexApiKeyProjectIdHint') }}
+{{ t('admin.accounts.vertexSaJsonEditHint') }}