Merge pull request #1977 from sholiverlee/vertex
feat: 支持 Vertex Service Account(Anthropic / Gemini)
This commit is contained in:
commit
63ef23108c
@ -145,13 +145,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
|
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
|
||||||
oAuthRefreshAPI := service.ProvideOAuthRefreshAPI(accountRepository, geminiTokenCache)
|
oAuthRefreshAPI := service.ProvideOAuthRefreshAPI(accountRepository, geminiTokenCache)
|
||||||
geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oAuthRefreshAPI)
|
geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oAuthRefreshAPI)
|
||||||
|
claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI)
|
||||||
gatewayCache := repository.NewGatewayCache(redisClient)
|
gatewayCache := repository.NewGatewayCache(redisClient)
|
||||||
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
|
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
|
||||||
schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig)
|
schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig)
|
||||||
antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oAuthRefreshAPI, tempUnschedCache)
|
antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oAuthRefreshAPI, tempUnschedCache)
|
||||||
internal500CounterCache := repository.NewInternal500CounterCache(redisClient)
|
internal500CounterCache := repository.NewInternal500CounterCache(redisClient)
|
||||||
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache)
|
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache)
|
||||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
|
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
|
||||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
|
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
|
||||||
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
||||||
@ -178,7 +179,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
billingService := service.NewBillingService(configConfig, pricingService)
|
billingService := service.NewBillingService(configConfig, pricingService)
|
||||||
identityService := service.NewIdentityService(identityCache)
|
identityService := service.NewIdentityService(identityCache)
|
||||||
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
||||||
claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI)
|
|
||||||
digestSessionStore := service.NewDigestSessionStore()
|
digestSessionStore := service.NewDigestSessionStore()
|
||||||
channelRepository := repository.NewChannelRepository(db)
|
channelRepository := repository.NewChannelRepository(db)
|
||||||
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
|
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
|
||||||
|
|||||||
@ -26,11 +26,12 @@ const (
|
|||||||
|
|
||||||
// Account type constants
|
// Account type constants
|
||||||
const (
|
const (
|
||||||
AccountTypeOAuth = "oauth" // OAuth类型账号(full scope: profile + inference)
|
AccountTypeOAuth = "oauth" // OAuth类型账号(full scope: profile + inference)
|
||||||
AccountTypeSetupToken = "setup-token" // Setup Token类型账号(inference only scope)
|
AccountTypeSetupToken = "setup-token" // Setup Token类型账号(inference only scope)
|
||||||
AccountTypeAPIKey = "apikey" // API Key类型账号
|
AccountTypeAPIKey = "apikey" // API Key类型账号
|
||||||
AccountTypeUpstream = "upstream" // 上游透传类型账号(通过 Base URL + API Key 连接上游)
|
AccountTypeUpstream = "upstream" // 上游透传类型账号(通过 Base URL + API Key 连接上游)
|
||||||
AccountTypeBedrock = "bedrock" // AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
|
AccountTypeBedrock = "bedrock" // AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
|
||||||
|
AccountTypeServiceAccount = "service_account" // Google Service Account 类型账号(用于 Vertex AI)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Redeem type constants
|
// Redeem type constants
|
||||||
|
|||||||
@ -98,7 +98,7 @@ type CreateAccountRequest struct {
|
|||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Notes *string `json:"notes"`
|
Notes *string `json:"notes"`
|
||||||
Platform string `json:"platform" binding:"required"`
|
Platform string `json:"platform" binding:"required"`
|
||||||
Type string `json:"type" binding:"required,oneof=oauth setup-token apikey upstream bedrock"`
|
Type string `json:"type" binding:"required,oneof=oauth setup-token apikey upstream bedrock service_account"`
|
||||||
Credentials map[string]any `json:"credentials" binding:"required"`
|
Credentials map[string]any `json:"credentials" binding:"required"`
|
||||||
Extra map[string]any `json:"extra"`
|
Extra map[string]any `json:"extra"`
|
||||||
ProxyID *int64 `json:"proxy_id"`
|
ProxyID *int64 `json:"proxy_id"`
|
||||||
@ -117,7 +117,7 @@ type CreateAccountRequest struct {
|
|||||||
type UpdateAccountRequest struct {
|
type UpdateAccountRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Notes *string `json:"notes"`
|
Notes *string `json:"notes"`
|
||||||
Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey upstream bedrock"`
|
Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey upstream bedrock service_account"`
|
||||||
Credentials map[string]any `json:"credentials"`
|
Credentials map[string]any `json:"credentials"`
|
||||||
Extra map[string]any `json:"extra"`
|
Extra map[string]any `json:"extra"`
|
||||||
ProxyID *int64 `json:"proxy_id"`
|
ProxyID *int64 `json:"proxy_id"`
|
||||||
|
|||||||
@ -64,6 +64,7 @@ func isOpenAIImageModel(model string) bool {
|
|||||||
type AccountTestService struct {
|
type AccountTestService struct {
|
||||||
accountRepo AccountRepository
|
accountRepo AccountRepository
|
||||||
geminiTokenProvider *GeminiTokenProvider
|
geminiTokenProvider *GeminiTokenProvider
|
||||||
|
claudeTokenProvider *ClaudeTokenProvider
|
||||||
antigravityGatewayService *AntigravityGatewayService
|
antigravityGatewayService *AntigravityGatewayService
|
||||||
httpUpstream HTTPUpstream
|
httpUpstream HTTPUpstream
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
@ -74,6 +75,7 @@ type AccountTestService struct {
|
|||||||
func NewAccountTestService(
|
func NewAccountTestService(
|
||||||
accountRepo AccountRepository,
|
accountRepo AccountRepository,
|
||||||
geminiTokenProvider *GeminiTokenProvider,
|
geminiTokenProvider *GeminiTokenProvider,
|
||||||
|
claudeTokenProvider *ClaudeTokenProvider,
|
||||||
antigravityGatewayService *AntigravityGatewayService,
|
antigravityGatewayService *AntigravityGatewayService,
|
||||||
httpUpstream HTTPUpstream,
|
httpUpstream HTTPUpstream,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
@ -82,6 +84,7 @@ func NewAccountTestService(
|
|||||||
return &AccountTestService{
|
return &AccountTestService{
|
||||||
accountRepo: accountRepo,
|
accountRepo: accountRepo,
|
||||||
geminiTokenProvider: geminiTokenProvider,
|
geminiTokenProvider: geminiTokenProvider,
|
||||||
|
claudeTokenProvider: claudeTokenProvider,
|
||||||
antigravityGatewayService: antigravityGatewayService,
|
antigravityGatewayService: antigravityGatewayService,
|
||||||
httpUpstream: httpUpstream,
|
httpUpstream: httpUpstream,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@ -210,6 +213,9 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
|||||||
if account.IsBedrock() {
|
if account.IsBedrock() {
|
||||||
return s.testBedrockAccountConnection(c, ctx, account, testModelID)
|
return s.testBedrockAccountConnection(c, ctx, account, testModelID)
|
||||||
}
|
}
|
||||||
|
if account.Type == AccountTypeServiceAccount {
|
||||||
|
return s.testClaudeVertexServiceAccountConnection(c, ctx, account, testModelID)
|
||||||
|
}
|
||||||
|
|
||||||
// Determine authentication method and API URL
|
// Determine authentication method and API URL
|
||||||
var authToken string
|
var authToken string
|
||||||
@ -313,6 +319,74 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
|||||||
return s.processClaudeStream(c, resp.Body)
|
return s.processClaudeStream(c, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AccountTestService) testClaudeVertexServiceAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string) error {
|
||||||
|
if mappedModel, matched := account.ResolveMappedModel(testModelID); matched {
|
||||||
|
testModelID = mappedModel
|
||||||
|
} else {
|
||||||
|
testModelID = normalizeVertexAnthropicModelID(claude.NormalizeModelID(testModelID))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||||
|
c.Writer.Header().Set("Connection", "keep-alive")
|
||||||
|
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
c.Writer.Flush()
|
||||||
|
|
||||||
|
payload, err := createTestPayload(testModelID)
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, "Failed to create test payload")
|
||||||
|
}
|
||||||
|
payloadBytes, _ := json.Marshal(payload)
|
||||||
|
vertexBody, err := buildVertexAnthropicRequestBody(payloadBytes)
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to create Vertex request body: %s", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.claudeTokenProvider == nil {
|
||||||
|
return s.sendErrorAndEnd(c, "Claude token provider not configured")
|
||||||
|
}
|
||||||
|
accessToken, err := s.claudeTokenProvider.GetAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to get service account access token: %s", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fullURL, err := buildVertexAnthropicURL(account.VertexProjectID(), account.VertexLocation(testModelID), testModelID, true)
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to build Vertex URL: %s", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(vertexBody))
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, "Failed to create request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
proxyURL := ""
|
||||||
|
if account.ProxyID != nil && account.Proxy != nil {
|
||||||
|
proxyURL = account.Proxy.URL()
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
errMsg := fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
if resp.StatusCode == http.StatusForbidden {
|
||||||
|
_ = s.accountRepo.SetError(ctx, account.ID, errMsg)
|
||||||
|
}
|
||||||
|
return s.sendErrorAndEnd(c, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.processClaudeStream(c, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
// testBedrockAccountConnection tests a Bedrock (SigV4 or API Key) account using non-streaming invoke
|
// testBedrockAccountConnection tests a Bedrock (SigV4 or API Key) account using non-streaming invoke
|
||||||
func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string) error {
|
func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string) error {
|
||||||
region := bedrockRuntimeRegion(account)
|
region := bedrockRuntimeRegion(account)
|
||||||
@ -711,8 +785,8 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
|
|||||||
testModelID = geminicli.DefaultTestModel
|
testModelID = geminicli.DefaultTestModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// For API Key accounts with model mapping, map the model
|
// For static upstream credentials with model mapping, map the model
|
||||||
if account.Type == AccountTypeAPIKey {
|
if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount {
|
||||||
mapping := account.GetModelMapping()
|
mapping := account.GetModelMapping()
|
||||||
if len(mapping) > 0 {
|
if len(mapping) > 0 {
|
||||||
if mappedModel, exists := mapping[testModelID]; exists {
|
if mappedModel, exists := mapping[testModelID]; exists {
|
||||||
@ -740,6 +814,8 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
|
|||||||
req, err = s.buildGeminiAPIKeyRequest(ctx, account, testModelID, payload)
|
req, err = s.buildGeminiAPIKeyRequest(ctx, account, testModelID, payload)
|
||||||
case AccountTypeOAuth:
|
case AccountTypeOAuth:
|
||||||
req, err = s.buildGeminiOAuthRequest(ctx, account, testModelID, payload)
|
req, err = s.buildGeminiOAuthRequest(ctx, account, testModelID, payload)
|
||||||
|
case AccountTypeServiceAccount:
|
||||||
|
req, err = s.buildGeminiServiceAccountRequest(ctx, account, testModelID, payload)
|
||||||
default:
|
default:
|
||||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type))
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type))
|
||||||
}
|
}
|
||||||
@ -893,6 +969,27 @@ func (s *AccountTestService) buildGeminiOAuthRequest(ctx context.Context, accoun
|
|||||||
return s.buildCodeAssistRequest(ctx, accessToken, projectID, modelID, payload)
|
return s.buildCodeAssistRequest(ctx, accessToken, projectID, modelID, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildCodeAssistRequest builds request for Google Code Assist API (used by Gemini CLI and Antigravity)
|
// buildCodeAssistRequest builds request for Google Code Assist API (used by Gemini CLI and Antigravity)
|
||||||
func (s *AccountTestService) buildCodeAssistRequest(ctx context.Context, accessToken, projectID, modelID string, payload []byte) (*http.Request, error) {
|
func (s *AccountTestService) buildCodeAssistRequest(ctx context.Context, accessToken, projectID, modelID string, payload []byte) (*http.Request, error) {
|
||||||
var inner map[string]any
|
var inner map[string]any
|
||||||
|
|||||||
@ -17,7 +17,7 @@ const (
|
|||||||
// ClaudeTokenCache token cache interface.
|
// ClaudeTokenCache token cache interface.
|
||||||
type ClaudeTokenCache = GeminiTokenCache
|
type ClaudeTokenCache = GeminiTokenCache
|
||||||
|
|
||||||
// ClaudeTokenProvider manages access_token for Claude OAuth accounts.
|
// ClaudeTokenProvider manages access_token for Claude OAuth and Vertex service account accounts.
|
||||||
type ClaudeTokenProvider struct {
|
type ClaudeTokenProvider struct {
|
||||||
accountRepo AccountRepository
|
accountRepo AccountRepository
|
||||||
tokenCache ClaudeTokenCache
|
tokenCache ClaudeTokenCache
|
||||||
@ -56,8 +56,11 @@ func (p *ClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
if account == nil {
|
if account == nil {
|
||||||
return "", errors.New("account is nil")
|
return "", errors.New("account is nil")
|
||||||
}
|
}
|
||||||
if account.Platform != PlatformAnthropic || account.Type != AccountTypeOAuth {
|
if account.Platform != PlatformAnthropic || (account.Type != AccountTypeOAuth && account.Type != AccountTypeServiceAccount) {
|
||||||
return "", errors.New("not an anthropic oauth account")
|
return "", errors.New("not an anthropic oauth or service account")
|
||||||
|
}
|
||||||
|
if account.Type == AccountTypeServiceAccount {
|
||||||
|
return p.getServiceAccountAccessToken(ctx, account)
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheKey := ClaudeTokenCacheKey(account)
|
cacheKey := ClaudeTokenCacheKey(account)
|
||||||
@ -157,3 +160,42 @@ func (p *ClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
|
|
||||||
return accessToken, nil
|
return accessToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *ClaudeTokenProvider) getServiceAccountAccessToken(ctx context.Context, account *Account) (string, error) {
|
||||||
|
key, err := parseVertexServiceAccountKey(account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cacheKey := vertexServiceAccountCacheKey(account, key)
|
||||||
|
|
||||||
|
if p.tokenCache != nil {
|
||||||
|
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locked := false
|
||||||
|
if p.tokenCache != nil {
|
||||||
|
var lockErr error
|
||||||
|
locked, lockErr = p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
|
||||||
|
if lockErr == nil && locked {
|
||||||
|
defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }()
|
||||||
|
} else if lockErr != nil {
|
||||||
|
slog.Warn("vertex_service_account_token_lock_failed", "account_id", account.ID, "error", lockErr)
|
||||||
|
} else {
|
||||||
|
time.Sleep(claudeLockWaitTime)
|
||||||
|
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, ttl, err := exchangeVertexServiceAccountToken(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if p.tokenCache != nil {
|
||||||
|
_ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
|
||||||
|
}
|
||||||
|
return accessToken, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -137,7 +137,7 @@ func (p *testClaudeTokenProvider) GetAccessToken(ctx context.Context, account *A
|
|||||||
return "", errors.New("account is nil")
|
return "", errors.New("account is nil")
|
||||||
}
|
}
|
||||||
if account.Platform != PlatformAnthropic || account.Type != AccountTypeOAuth {
|
if account.Platform != PlatformAnthropic || account.Type != AccountTypeOAuth {
|
||||||
return "", errors.New("not an anthropic oauth account")
|
return "", errors.New("not an anthropic oauth or service account")
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheKey := ClaudeTokenCacheKey(account)
|
cacheKey := ClaudeTokenCacheKey(account)
|
||||||
@ -371,7 +371,7 @@ func TestClaudeTokenProvider_WrongPlatform(t *testing.T) {
|
|||||||
|
|
||||||
token, err := provider.GetAccessToken(context.Background(), account)
|
token, err := provider.GetAccessToken(context.Background(), account)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Contains(t, err.Error(), "not an anthropic oauth account")
|
require.Contains(t, err.Error(), "not an anthropic oauth or service account")
|
||||||
require.Empty(t, token)
|
require.Empty(t, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -385,7 +385,7 @@ func TestClaudeTokenProvider_WrongAccountType(t *testing.T) {
|
|||||||
|
|
||||||
token, err := provider.GetAccessToken(context.Background(), account)
|
token, err := provider.GetAccessToken(context.Background(), account)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Contains(t, err.Error(), "not an anthropic oauth account")
|
require.Contains(t, err.Error(), "not an anthropic oauth or service account")
|
||||||
require.Empty(t, token)
|
require.Empty(t, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -399,7 +399,7 @@ func TestClaudeTokenProvider_SetupTokenType(t *testing.T) {
|
|||||||
|
|
||||||
token, err := provider.GetAccessToken(context.Background(), account)
|
token, err := provider.GetAccessToken(context.Background(), account)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Contains(t, err.Error(), "not an anthropic oauth account")
|
require.Contains(t, err.Error(), "not an anthropic oauth or service account")
|
||||||
require.Empty(t, token)
|
require.Empty(t, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,11 +41,12 @@ const (
|
|||||||
|
|
||||||
// Account type constants
|
// Account type constants
|
||||||
const (
|
const (
|
||||||
AccountTypeOAuth = domain.AccountTypeOAuth // OAuth类型账号(full scope: profile + inference)
|
AccountTypeOAuth = domain.AccountTypeOAuth // OAuth类型账号(full scope: profile + inference)
|
||||||
AccountTypeSetupToken = domain.AccountTypeSetupToken // Setup Token类型账号(inference only scope)
|
AccountTypeSetupToken = domain.AccountTypeSetupToken // Setup Token类型账号(inference only scope)
|
||||||
AccountTypeAPIKey = domain.AccountTypeAPIKey // API Key类型账号
|
AccountTypeAPIKey = domain.AccountTypeAPIKey // API Key类型账号
|
||||||
AccountTypeUpstream = domain.AccountTypeUpstream // 上游透传类型账号(通过 Base URL + API Key 连接上游)
|
AccountTypeUpstream = domain.AccountTypeUpstream // 上游透传类型账号(通过 Base URL + API Key 连接上游)
|
||||||
AccountTypeBedrock = domain.AccountTypeBedrock // AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
|
AccountTypeBedrock = domain.AccountTypeBedrock // AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
|
||||||
|
AccountTypeServiceAccount = domain.AccountTypeServiceAccount // Google Service Account 类型账号(用于 Vertex AI)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Redeem type constants
|
// Redeem type constants
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGatewayService_BuildAnthropicVertexServiceAccountRequest(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||||
|
c.Request.Header.Set("Authorization", "Bearer inbound-token")
|
||||||
|
c.Request.Header.Set("X-Api-Key", "inbound-api-key")
|
||||||
|
c.Request.Header.Set("Anthropic-Version", "2023-06-01")
|
||||||
|
c.Request.Header.Set("Anthropic-Beta", "interleaved-thinking-2025-05-14")
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
ID: 301,
|
||||||
|
Platform: PlatformAnthropic,
|
||||||
|
Type: AccountTypeServiceAccount,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"project_id": "vertex-proj",
|
||||||
|
"location": "us-east5",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body := []byte(`{"model":"claude-sonnet-4-5","stream":false,"max_tokens":32,"messages":[{"role":"user","content":"hello"}]}`)
|
||||||
|
|
||||||
|
svc := &GatewayService{}
|
||||||
|
req, err := svc.buildUpstreamRequest(
|
||||||
|
context.Background(),
|
||||||
|
c,
|
||||||
|
account,
|
||||||
|
body,
|
||||||
|
"vertex-token",
|
||||||
|
"service_account",
|
||||||
|
"claude-sonnet-4-5@20250929",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "https://us-east5-aiplatform.googleapis.com/v1/projects/vertex-proj/locations/us-east5/publishers/anthropic/models/claude-sonnet-4-5@20250929:rawPredict", req.URL.String())
|
||||||
|
require.Equal(t, "Bearer vertex-token", getHeaderRaw(req.Header, "authorization"))
|
||||||
|
require.Empty(t, getHeaderRaw(req.Header, "x-api-key"))
|
||||||
|
require.Empty(t, getHeaderRaw(req.Header, "anthropic-version"))
|
||||||
|
require.Equal(t, "interleaved-thinking-2025-05-14", getHeaderRaw(req.Header, "anthropic-beta"))
|
||||||
|
|
||||||
|
got := readRequestBodyForTest(t, req)
|
||||||
|
require.Equal(t, "", gjson.GetBytes(got, "model").String())
|
||||||
|
require.Equal(t, vertexAnthropicVersion, gjson.GetBytes(got, "anthropic_version").String())
|
||||||
|
require.Equal(t, "hello", gjson.GetBytes(got, "messages.0.content").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func readRequestBodyForTest(t *testing.T, req *http.Request) []byte {
|
||||||
|
t.Helper()
|
||||||
|
require.NotNil(t, req.Body)
|
||||||
|
body, err := io.ReadAll(req.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return body
|
||||||
|
}
|
||||||
@ -3597,7 +3597,11 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo
|
|||||||
}
|
}
|
||||||
// OAuth/SetupToken 账号使用 Anthropic 标准映射(短ID → 长ID)
|
// OAuth/SetupToken 账号使用 Anthropic 标准映射(短ID → 长ID)
|
||||||
if account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey {
|
if account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey {
|
||||||
requestedModel = claude.NormalizeModelID(requestedModel)
|
if account.Type == AccountTypeServiceAccount {
|
||||||
|
requestedModel = normalizeVertexAnthropicModelID(claude.NormalizeModelID(requestedModel))
|
||||||
|
} else {
|
||||||
|
requestedModel = claude.NormalizeModelID(requestedModel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 其他平台使用账户的模型支持检查
|
// 其他平台使用账户的模型支持检查
|
||||||
return account.IsModelSupported(requestedModel)
|
return account.IsModelSupported(requestedModel)
|
||||||
@ -3617,6 +3621,18 @@ func (s *GatewayService) GetAccessToken(ctx context.Context, account *Account) (
|
|||||||
return apiKey, "apikey", nil
|
return apiKey, "apikey", nil
|
||||||
case AccountTypeBedrock:
|
case AccountTypeBedrock:
|
||||||
return "", "bedrock", nil // Bedrock 使用 SigV4 签名或 API Key,由 forwardBedrock 处理
|
return "", "bedrock", nil // Bedrock 使用 SigV4 签名或 API Key,由 forwardBedrock 处理
|
||||||
|
case AccountTypeServiceAccount:
|
||||||
|
if account.Platform != PlatformAnthropic {
|
||||||
|
return "", "", fmt.Errorf("unsupported service account platform: %s", account.Platform)
|
||||||
|
}
|
||||||
|
if s.claudeTokenProvider == nil {
|
||||||
|
return "", "", errors.New("claude token provider not configured")
|
||||||
|
}
|
||||||
|
accessToken, err := s.claudeTokenProvider.GetAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return accessToken, "service_account", nil
|
||||||
default:
|
default:
|
||||||
return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
|
return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
|
||||||
}
|
}
|
||||||
@ -4219,6 +4235,18 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
|||||||
mappingSource = "account"
|
mappingSource = "account"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount {
|
||||||
|
if candidate, matched := account.ResolveMappedModel(reqModel); matched {
|
||||||
|
mappedModel = candidate
|
||||||
|
mappingSource = "account"
|
||||||
|
} else {
|
||||||
|
normalized := normalizeVertexAnthropicModelID(claude.NormalizeModelID(reqModel))
|
||||||
|
if normalized != reqModel {
|
||||||
|
mappedModel = normalized
|
||||||
|
mappingSource = "vertex"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey {
|
if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey {
|
||||||
normalized := claude.NormalizeModelID(reqModel)
|
normalized := claude.NormalizeModelID(reqModel)
|
||||||
if normalized != reqModel {
|
if normalized != reqModel {
|
||||||
@ -5688,6 +5716,10 @@ func (s *GatewayService) handleBedrockNonStreamingResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, error) {
|
func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, error) {
|
||||||
|
if account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount {
|
||||||
|
return s.buildUpstreamRequestAnthropicVertex(ctx, c, account, body, token, modelID, reqStream)
|
||||||
|
}
|
||||||
|
|
||||||
// 确定目标URL
|
// 确定目标URL
|
||||||
targetURL := claudeAPIURL
|
targetURL := claudeAPIURL
|
||||||
if account.Type == AccountTypeAPIKey {
|
if account.Type == AccountTypeAPIKey {
|
||||||
@ -5874,6 +5906,60 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
|
|||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *GatewayService) buildUpstreamRequestAnthropicVertex(
|
||||||
|
ctx context.Context,
|
||||||
|
c *gin.Context,
|
||||||
|
account *Account,
|
||||||
|
body []byte,
|
||||||
|
token string,
|
||||||
|
modelID string,
|
||||||
|
reqStream bool,
|
||||||
|
) (*http.Request, error) {
|
||||||
|
vertexBody, err := buildVertexAnthropicRequestBody(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
setOpsUpstreamRequestBody(c, vertexBody)
|
||||||
|
fullURL, err := buildVertexAnthropicURL(account.VertexProjectID(), account.VertexLocation(modelID), modelID, reqStream)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(vertexBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if c != nil && c.Request != nil {
|
||||||
|
for key, values := range c.Request.Header {
|
||||||
|
lowerKey := strings.ToLower(strings.TrimSpace(key))
|
||||||
|
if !allowedHeaders[lowerKey] || lowerKey == "anthropic-version" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wireKey := resolveWireCasing(key)
|
||||||
|
for _, v := range values {
|
||||||
|
addHeaderRaw(req.Header, wireKey, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Del("authorization")
|
||||||
|
req.Header.Del("x-api-key")
|
||||||
|
req.Header.Del("x-goog-api-key")
|
||||||
|
req.Header.Del("cookie")
|
||||||
|
req.Header.Del("anthropic-version")
|
||||||
|
setHeaderRaw(req.Header, "authorization", "Bearer "+token)
|
||||||
|
setHeaderRaw(req.Header, "content-type", "application/json")
|
||||||
|
|
||||||
|
s.debugLogGatewaySnapshot("UPSTREAM_FORWARD_VERTEX_ANTHROPIC", req.Header, vertexBody, map[string]string{
|
||||||
|
"url": req.URL.String(),
|
||||||
|
"token_type": "service_account",
|
||||||
|
"model": modelID,
|
||||||
|
"stream": strconv.FormatBool(reqStream),
|
||||||
|
})
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
// getBetaHeader 处理anthropic-beta header
|
// getBetaHeader 处理anthropic-beta header
|
||||||
// 对于OAuth账号,需要确保包含oauth-2025-04-20
|
// 对于OAuth账号,需要确保包含oauth-2025-04-20
|
||||||
func (s *GatewayService) getBetaHeader(modelID string, clientBetaHeader string) string {
|
func (s *GatewayService) getBetaHeader(modelID string, clientBetaHeader string) string {
|
||||||
|
|||||||
@ -579,7 +579,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
|||||||
|
|
||||||
originalModel := req.Model
|
originalModel := req.Model
|
||||||
mappedModel := req.Model
|
mappedModel := req.Model
|
||||||
if account.Type == AccountTypeAPIKey {
|
if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount {
|
||||||
mappedModel = account.GetMappedModel(req.Model)
|
mappedModel = account.GetMappedModel(req.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -712,6 +712,36 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
|||||||
}
|
}
|
||||||
requestIDHeader = "x-request-id"
|
requestIDHeader = "x-request-id"
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
restGeminiReq := normalizeGeminiRequestForAIStudio(geminiReq)
|
||||||
|
upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(restGeminiReq))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
upstreamReq.Header.Set("Content-Type", "application/json")
|
||||||
|
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
return upstreamReq, "x-request-id", nil
|
||||||
|
}
|
||||||
|
requestIDHeader = "x-request-id"
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported account type: %s", account.Type)
|
return nil, fmt.Errorf("unsupported account type: %s", account.Type)
|
||||||
}
|
}
|
||||||
@ -1094,7 +1124,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
|||||||
body = ensureGeminiFunctionCallThoughtSignatures(body)
|
body = ensureGeminiFunctionCallThoughtSignatures(body)
|
||||||
|
|
||||||
mappedModel := originalModel
|
mappedModel := originalModel
|
||||||
if account.Type == AccountTypeAPIKey {
|
if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount {
|
||||||
mappedModel = account.GetMappedModel(originalModel)
|
mappedModel = account.GetMappedModel(originalModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1213,6 +1243,31 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
|||||||
}
|
}
|
||||||
requestIDHeader = "x-request-id"
|
requestIDHeader = "x-request-id"
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
upstreamReq.Header.Set("Content-Type", "application/json")
|
||||||
|
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
return upstreamReq, "x-request-id", nil
|
||||||
|
}
|
||||||
|
requestIDHeader = "x-request-id"
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Unsupported account type: "+account.Type)
|
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Unsupported account type: "+account.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const (
|
|||||||
geminiTokenCacheSkew = 5 * time.Minute
|
geminiTokenCacheSkew = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// GeminiTokenProvider manages access_token for Gemini OAuth accounts.
|
// GeminiTokenProvider manages access_token for Gemini OAuth and Vertex service account accounts.
|
||||||
type GeminiTokenProvider struct {
|
type GeminiTokenProvider struct {
|
||||||
accountRepo AccountRepository
|
accountRepo AccountRepository
|
||||||
tokenCache GeminiTokenCache
|
tokenCache GeminiTokenCache
|
||||||
@ -53,8 +53,11 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
if account == nil {
|
if account == nil {
|
||||||
return "", errors.New("account is nil")
|
return "", errors.New("account is nil")
|
||||||
}
|
}
|
||||||
if account.Platform != PlatformGemini || account.Type != AccountTypeOAuth {
|
if account.Platform != PlatformGemini || (account.Type != AccountTypeOAuth && account.Type != AccountTypeServiceAccount) {
|
||||||
return "", errors.New("not a gemini oauth account")
|
return "", errors.New("not a gemini oauth or service account")
|
||||||
|
}
|
||||||
|
if account.Type == AccountTypeServiceAccount {
|
||||||
|
return p.getServiceAccountAccessToken(ctx, account)
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheKey := GeminiTokenCacheKey(account)
|
cacheKey := GeminiTokenCacheKey(account)
|
||||||
@ -168,7 +171,51 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
return accessToken, nil
|
return accessToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *GeminiTokenProvider) getServiceAccountAccessToken(ctx context.Context, account *Account) (string, error) {
|
||||||
|
key, err := parseVertexServiceAccountKey(account)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cacheKey := vertexServiceAccountCacheKey(account, key)
|
||||||
|
|
||||||
|
if p.tokenCache != nil {
|
||||||
|
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locked := false
|
||||||
|
if p.tokenCache != nil {
|
||||||
|
var lockErr error
|
||||||
|
locked, lockErr = p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
|
||||||
|
if lockErr == nil && locked {
|
||||||
|
defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }()
|
||||||
|
} else if lockErr != nil {
|
||||||
|
slog.Warn("vertex_service_account_token_lock_failed", "account_id", account.ID, "error", lockErr)
|
||||||
|
} else {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, ttl, err := exchangeVertexServiceAccountToken(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if p.tokenCache != nil {
|
||||||
|
_ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
|
||||||
|
}
|
||||||
|
return accessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
func GeminiTokenCacheKey(account *Account) string {
|
func GeminiTokenCacheKey(account *Account) string {
|
||||||
|
if account != nil && account.Type == AccountTypeServiceAccount {
|
||||||
|
if key, err := parseVertexServiceAccountKey(account); err == nil {
|
||||||
|
return vertexServiceAccountCacheKey(account, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||||
if projectID != "" {
|
if projectID != "" {
|
||||||
return "gemini:" + projectID
|
return "gemini:" + projectID
|
||||||
|
|||||||
303
backend/internal/service/vertex_service_account.go
Normal file
303
backend/internal/service/vertex_service_account.go
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
vertexDefaultLocation = "us-central1"
|
||||||
|
vertexDefaultTokenURL = "https://oauth2.googleapis.com/token"
|
||||||
|
vertexCloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"
|
||||||
|
vertexServiceAccountCacheSkew = 5 * time.Minute
|
||||||
|
vertexAnthropicVersion = "vertex-2023-10-16"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
vertexLocationPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||||
|
vertexAnthropicDatedModelIDPattern = regexp.MustCompile(`^(.+)-([0-9]{8})$`)
|
||||||
|
vertexAnthropicAlreadyDatedIDPattern = regexp.MustCompile(`^.+@[0-9]{8}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type vertexServiceAccountKey struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
PrivateKeyID string `json:"private_key_id"`
|
||||||
|
PrivateKey string `json:"private_key"`
|
||||||
|
ClientEmail string `json:"client_email"`
|
||||||
|
TokenURI string `json:"token_uri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vertexTokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorDesc string `json:"error_description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Account) IsVertexServiceAccount() bool {
|
||||||
|
return a != nil && a.Type == AccountTypeServiceAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Account) VertexProjectID() string {
|
||||||
|
if a == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(a.GetCredential("project_id")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
key, err := parseVertexServiceAccountKey(a)
|
||||||
|
if err == nil {
|
||||||
|
return strings.TrimSpace(key.ProjectID)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Account) VertexLocation(model string) string {
|
||||||
|
if a == nil {
|
||||||
|
return vertexDefaultLocation
|
||||||
|
}
|
||||||
|
if model != "" && a.Credentials != nil {
|
||||||
|
if raw, ok := a.Credentials["vertex_model_locations"].(map[string]any); ok {
|
||||||
|
if loc, ok := raw[model].(string); ok && strings.TrimSpace(loc) != "" {
|
||||||
|
return strings.TrimSpace(loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(a.GetCredential("location")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(a.GetCredential("vertex_location")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return vertexDefaultLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVertexServiceAccountKey(account *Account) (*vertexServiceAccountKey, error) {
|
||||||
|
if account == nil || account.Credentials == nil {
|
||||||
|
return nil, errors.New("service account credentials not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw := strings.TrimSpace(account.GetCredential("service_account_json")); raw != "" {
|
||||||
|
return parseVertexServiceAccountJSON([]byte(raw))
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(account.GetCredential("service_account")); raw != "" {
|
||||||
|
return parseVertexServiceAccountJSON([]byte(raw))
|
||||||
|
}
|
||||||
|
if nested, ok := account.Credentials["service_account_json"].(map[string]any); ok {
|
||||||
|
b, _ := json.Marshal(nested)
|
||||||
|
return parseVertexServiceAccountJSON(b)
|
||||||
|
}
|
||||||
|
if nested, ok := account.Credentials["service_account"].(map[string]any); ok {
|
||||||
|
b, _ := json.Marshal(nested)
|
||||||
|
return parseVertexServiceAccountJSON(b)
|
||||||
|
}
|
||||||
|
return nil, errors.New("service_account_json not found in credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVertexServiceAccountJSON(raw []byte) (*vertexServiceAccountKey, error) {
|
||||||
|
var key vertexServiceAccountKey
|
||||||
|
if err := json.Unmarshal(raw, &key); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid service account json: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(key.ClientEmail) == "" {
|
||||||
|
return nil, errors.New("service account json missing client_email")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(key.PrivateKey) == "" {
|
||||||
|
return nil, errors.New("service account json missing private_key")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(key.ProjectID) == "" {
|
||||||
|
return nil, errors.New("service account json missing project_id")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(key.TokenURI) == "" {
|
||||||
|
key.TokenURI = vertexDefaultTokenURL
|
||||||
|
}
|
||||||
|
return &key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func vertexServiceAccountCacheKey(account *Account, key *vertexServiceAccountKey) string {
|
||||||
|
fingerprint := ""
|
||||||
|
if key != nil {
|
||||||
|
sum := sha256.Sum256([]byte(key.ClientEmail + "\x00" + key.PrivateKeyID))
|
||||||
|
fingerprint = hex.EncodeToString(sum[:8])
|
||||||
|
}
|
||||||
|
if fingerprint == "" && account != nil {
|
||||||
|
fingerprint = fmt.Sprintf("account:%d", account.ID)
|
||||||
|
}
|
||||||
|
return "vertex:service_account:" + fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
func exchangeVertexServiceAccountToken(ctx context.Context, key *vertexServiceAccountKey) (string, time.Duration, error) {
|
||||||
|
now := time.Now()
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"iss": key.ClientEmail,
|
||||||
|
"scope": vertexCloudPlatformScope,
|
||||||
|
"aud": key.TokenURI,
|
||||||
|
"iat": now.Unix(),
|
||||||
|
"exp": now.Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
if strings.TrimSpace(key.PrivateKeyID) != "" {
|
||||||
|
token.Header["kid"] = key.PrivateKeyID
|
||||||
|
}
|
||||||
|
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(key.PrivateKey))
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("parse service account private key: %w", err)
|
||||||
|
}
|
||||||
|
assertion, err := token.SignedString(privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("sign service account assertion: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||||||
|
values.Set("assertion", assertion)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, key.TokenURI, strings.NewReader(values.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("service account token request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
var parsed vertexTokenResponse
|
||||||
|
_ = json.Unmarshal(body, &parsed)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
msg := strings.TrimSpace(parsed.ErrorDesc)
|
||||||
|
if msg == "" {
|
||||||
|
msg = strings.TrimSpace(parsed.Error)
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = string(bytes.TrimSpace(body))
|
||||||
|
}
|
||||||
|
return "", 0, fmt.Errorf("service account token request returned %d: %s", resp.StatusCode, msg)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(parsed.AccessToken) == "" {
|
||||||
|
return "", 0, errors.New("service account token response missing access_token")
|
||||||
|
}
|
||||||
|
ttl := time.Duration(parsed.ExpiresIn) * time.Second
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = time.Hour
|
||||||
|
}
|
||||||
|
if ttl > vertexServiceAccountCacheSkew {
|
||||||
|
ttl -= vertexServiceAccountCacheSkew
|
||||||
|
}
|
||||||
|
return parsed.AccessToken, ttl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVertexGeminiURL(projectID, location, model, action string, stream bool) (string, error) {
|
||||||
|
projectID = strings.TrimSpace(projectID)
|
||||||
|
location = strings.TrimSpace(location)
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
action = strings.TrimSpace(action)
|
||||||
|
if projectID == "" {
|
||||||
|
return "", errors.New("vertex project_id is required")
|
||||||
|
}
|
||||||
|
if location == "" {
|
||||||
|
location = vertexDefaultLocation
|
||||||
|
}
|
||||||
|
if !vertexLocationPattern.MatchString(location) {
|
||||||
|
return "", fmt.Errorf("invalid vertex location: %s", location)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
host := fmt.Sprintf("%s-aiplatform.googleapis.com", location)
|
||||||
|
if location == "global" {
|
||||||
|
host = "aiplatform.googleapis.com"
|
||||||
|
}
|
||||||
|
u := fmt.Sprintf(
|
||||||
|
"https://%s/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
|
||||||
|
host,
|
||||||
|
url.PathEscape(projectID),
|
||||||
|
url.PathEscape(location),
|
||||||
|
url.PathEscape(model),
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
if stream {
|
||||||
|
u += "?alt=sse"
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVertexAnthropicURL(projectID, location, model string, stream bool) (string, error) {
|
||||||
|
projectID = strings.TrimSpace(projectID)
|
||||||
|
location = strings.TrimSpace(location)
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if projectID == "" {
|
||||||
|
return "", errors.New("vertex project_id is required")
|
||||||
|
}
|
||||||
|
if location == "" {
|
||||||
|
location = vertexDefaultLocation
|
||||||
|
}
|
||||||
|
if !vertexLocationPattern.MatchString(location) {
|
||||||
|
return "", fmt.Errorf("invalid vertex location: %s", location)
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
return "", errors.New("vertex model is required")
|
||||||
|
}
|
||||||
|
action := "rawPredict"
|
||||||
|
if stream {
|
||||||
|
action = "streamRawPredict"
|
||||||
|
}
|
||||||
|
host := fmt.Sprintf("%s-aiplatform.googleapis.com", location)
|
||||||
|
if location == "global" {
|
||||||
|
host = "aiplatform.googleapis.com"
|
||||||
|
}
|
||||||
|
escapedModel := strings.ReplaceAll(url.PathEscape(model), "%40", "@")
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"https://%s/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s",
|
||||||
|
host,
|
||||||
|
url.PathEscape(projectID),
|
||||||
|
url.PathEscape(location),
|
||||||
|
escapedModel,
|
||||||
|
action,
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeVertexAnthropicModelID(model string) string {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model == "" || vertexAnthropicAlreadyDatedIDPattern.MatchString(model) {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
if m := vertexAnthropicDatedModelIDPattern.FindStringSubmatch(model); len(m) == 3 {
|
||||||
|
return m[1] + "@" + m[2]
|
||||||
|
}
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVertexAnthropicRequestBody(body []byte) ([]byte, error) {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse anthropic vertex request body: %w", err)
|
||||||
|
}
|
||||||
|
delete(payload, "model")
|
||||||
|
payload["anthropic_version"] = vertexAnthropicVersion
|
||||||
|
return json.Marshal(payload)
|
||||||
|
}
|
||||||
77
backend/internal/service/vertex_service_account_test.go
Normal file
77
backend/internal/service/vertex_service_account_test.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildVertexGeminiURL(t *testing.T) {
|
||||||
|
got, err := buildVertexGeminiURL("my-project", "us-central1", "gemini-3-pro", "streamGenerateContent", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-3-pro:streamGenerateContent?alt=sse", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildVertexGeminiURLUsesGlobalEndpointHost(t *testing.T) {
|
||||||
|
got, err := buildVertexGeminiURL("my-project", "global", "gemini-3-flash-preview", "streamGenerateContent", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
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 TestBuildVertexAnthropicURL(t *testing.T) {
|
||||||
|
got, err := buildVertexAnthropicURL("my-project", "us-east5", "claude-sonnet-4-5@20250929", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "https://us-east5-aiplatform.googleapis.com/v1/projects/my-project/locations/us-east5/publishers/anthropic/models/claude-sonnet-4-5@20250929:rawPredict", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildVertexAnthropicURLUsesGlobalEndpointHost(t *testing.T) {
|
||||||
|
got, err := buildVertexAnthropicURL("my-project", "global", "claude-haiku-4-5@20251001", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/anthropic/models/claude-haiku-4-5@20251001:streamRawPredict", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeVertexAnthropicModelID(t *testing.T) {
|
||||||
|
require.Equal(t, "claude-sonnet-4-5@20250929", normalizeVertexAnthropicModelID("claude-sonnet-4-5-20250929"))
|
||||||
|
require.Equal(t, "claude-sonnet-4-5@20250929", normalizeVertexAnthropicModelID("claude-sonnet-4-5@20250929"))
|
||||||
|
require.Equal(t, "claude-sonnet-4-6", normalizeVertexAnthropicModelID("claude-sonnet-4-6"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildVertexAnthropicRequestBody(t *testing.T) {
|
||||||
|
got, err := buildVertexAnthropicRequestBody([]byte(`{"model":"claude-sonnet-4-5","anthropic_version":"2023-06-01","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "", gjson.GetBytes(got, "model").String())
|
||||||
|
require.Equal(t, vertexAnthropicVersion, gjson.GetBytes(got, "anthropic_version").String())
|
||||||
|
require.Equal(t, int64(64), gjson.GetBytes(got, "max_tokens").Int())
|
||||||
|
require.Equal(t, "hi", gjson.GetBytes(got, "messages.0.content").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildVertexGeminiURLRejectsInvalidLocation(t *testing.T) {
|
||||||
|
_, err := buildVertexGeminiURL("my-project", "us-central1/path", "gemini-3-pro", "generateContent", false)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "invalid vertex location")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVertexServiceAccountKey(t *testing.T) {
|
||||||
|
raw := `{
|
||||||
|
"type": "service_account",
|
||||||
|
"project_id": "vertex-proj",
|
||||||
|
"private_key_id": "kid",
|
||||||
|
"private_key": "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n",
|
||||||
|
"client_email": "svc@vertex-proj.iam.gserviceaccount.com"
|
||||||
|
}`
|
||||||
|
account := &Account{
|
||||||
|
Type: AccountTypeServiceAccount,
|
||||||
|
Platform: PlatformGemini,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"service_account_json": raw,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
key, err := parseVertexServiceAccountKey(account)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "vertex-proj", key.ProjectID)
|
||||||
|
require.Equal(t, "svc@vertex-proj.iam.gserviceaccount.com", key.ClientEmail)
|
||||||
|
require.Equal(t, vertexDefaultTokenURL, key.TokenURI)
|
||||||
|
require.True(t, strings.Contains(key.PrivateKey, "BEGIN PRIVATE KEY"))
|
||||||
|
}
|
||||||
@ -332,6 +332,37 @@
|
|||||||
|
|
||||||
<!-- Usage data or unlimited flow -->
|
<!-- Usage data or unlimited flow -->
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-if="showGeminiTodayStats && todayStats"
|
||||||
|
class="mb-0.5 flex items-center"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||||
|
{{ formatKeyRequests }} req
|
||||||
|
</span>
|
||||||
|
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||||
|
{{ formatKeyTokens }}
|
||||||
|
</span>
|
||||||
|
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800" :title="t('usage.accountBilled')">
|
||||||
|
A ${{ formatKeyCost }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="todayStats.user_cost != null"
|
||||||
|
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
|
||||||
|
:title="t('usage.userBilled')"
|
||||||
|
>
|
||||||
|
U ${{ formatKeyUserCost }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="showGeminiTodayStats && todayStatsLoading"
|
||||||
|
class="mb-0.5 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<div class="h-3 w-10 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||||
|
<div class="h-3 w-8 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||||
|
<div class="h-3 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||||
|
</div>
|
||||||
<div v-if="loading" class="space-y-1">
|
<div v-if="loading" class="space-y-1">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||||
@ -512,6 +543,10 @@ const shouldFetchUsage = computed(() => {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showGeminiTodayStats = computed(() => {
|
||||||
|
return props.account.platform === 'gemini' && props.account.type === 'service_account'
|
||||||
|
})
|
||||||
|
|
||||||
const geminiUsageAvailable = computed(() => {
|
const geminiUsageAvailable = computed(() => {
|
||||||
return (
|
return (
|
||||||
!!usageInfo.value?.gemini_shared_daily ||
|
!!usageInfo.value?.gemini_shared_daily ||
|
||||||
|
|||||||
@ -153,7 +153,7 @@
|
|||||||
<!-- Account Type Selection (Anthropic) -->
|
<!-- Account Type Selection (Anthropic) -->
|
||||||
<div v-if="form.platform === 'anthropic'">
|
<div v-if="form.platform === 'anthropic'">
|
||||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||||
<div class="mt-2 grid grid-cols-3 gap-3" data-tour="account-form-type">
|
<div class="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-4" data-tour="account-form-type">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="accountCategory = 'oauth-based'"
|
@click="accountCategory = 'oauth-based'"
|
||||||
@ -244,6 +244,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="accountCategory = 'service_account'"
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||||
|
accountCategory === 'service_account'
|
||||||
|
? 'border-sky-500 bg-sky-50 dark:bg-sky-900/20'
|
||||||
|
: 'border-gray-200 hover:border-sky-300 dark:border-dark-600 dark:hover:border-sky-700'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||||
|
accountCategory === 'service_account'
|
||||||
|
? 'bg-sky-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Icon name="cloud" size="sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="block text-sm font-medium text-gray-900 dark:text-white">Vertex</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">Service Account</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="accountCategory === 'service_account'"
|
||||||
|
class="mt-3 rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-800 dark:border-sky-800/40 dark:bg-sky-900/20 dark:text-sky-200"
|
||||||
|
>
|
||||||
|
<p>使用 Google Cloud Service Account JSON 通过 Vertex AI 调用 Anthropic Claude。建议配置模型映射,将客户端 Claude 模型名映射到 Vertex 模型 ID。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -302,6 +335,7 @@
|
|||||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.responsesApi') }}</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.responsesApi') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -320,7 +354,7 @@
|
|||||||
{{ t('admin.accounts.gemini.helpButton') }}
|
{{ t('admin.accounts.gemini.helpButton') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 grid grid-cols-2 gap-3" data-tour="account-form-type">
|
<div class="mt-2 grid grid-cols-3 gap-3" data-tour="account-form-type">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="accountCategory = 'oauth-based'"
|
@click="accountCategory = 'oauth-based'"
|
||||||
@ -392,6 +426,36 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="accountCategory = 'service_account'"
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||||
|
accountCategory === 'service_account'
|
||||||
|
? 'border-sky-500 bg-sky-50 dark:bg-sky-900/20'
|
||||||
|
: 'border-gray-200 hover:border-sky-300 dark:border-dark-600 dark:hover:border-sky-700'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||||
|
accountCategory === 'service_account'
|
||||||
|
? 'bg-sky-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Icon name="cloud" size="sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Vertex
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Service Account
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -411,6 +475,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="accountCategory === 'service_account'"
|
||||||
|
class="mt-3 rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-800 dark:border-sky-800/40 dark:bg-sky-900/20 dark:text-sky-200"
|
||||||
|
>
|
||||||
|
<p>使用 Google Cloud Service Account JSON 访问 Vertex AI Gemini。建议将 Vertex 账号放入独立分组,避免和 AI Studio/Gemini OAuth 同模型混调。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- OAuth Type Selection (only show when oauth-based is selected) -->
|
<!-- OAuth Type Selection (only show when oauth-based is selected) -->
|
||||||
<div v-if="accountCategory === 'oauth-based'" class="mt-4">
|
<div v-if="accountCategory === 'oauth-based'" class="mt-4">
|
||||||
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label>
|
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label>
|
||||||
@ -610,7 +681,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tier selection (used as fallback when auto-detection is unavailable/fails) -->
|
<!-- Tier selection (used as fallback when auto-detection is unavailable/fails) -->
|
||||||
<div class="mt-4">
|
<div v-if="accountCategory !== 'service_account'" class="mt-4">
|
||||||
<label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label>
|
<label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<select
|
<select
|
||||||
@ -729,6 +800,96 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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 ? '已读取 Service Account JSON' : '拖入 Service Account JSON' }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ vertexClientEmail ? '密钥内容不会在表单中显示。' : '把 .json 文件拖到这里,或点击按钮选择文件。' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary shrink-0"
|
||||||
|
@click="vertexServiceAccountFileInput?.click()"
|
||||||
|
>
|
||||||
|
<Icon name="upload" size="sm" />
|
||||||
|
选择 JSON
|
||||||
|
</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">上传或拖入 JSON 后会自动读取 project_id,密钥内容仅用于创建账号提交。</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="从 JSON 自动读取"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="input-label">Location</label>
|
||||||
|
<select
|
||||||
|
v-model="vertexLocation"
|
||||||
|
required
|
||||||
|
class="input font-mono"
|
||||||
|
>
|
||||||
|
<optgroup
|
||||||
|
v-for="group in vertexLocationOptions"
|
||||||
|
: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">不同 Vertex 模型可用 location 可能不同,这里选择账号默认 endpoint location。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Antigravity model restriction (applies to OAuth + Upstream) -->
|
<!-- Antigravity model restriction (applies to OAuth + Upstream) -->
|
||||||
<!-- Antigravity 只支持模型映射模式,不支持白名单模式 -->
|
<!-- Antigravity 只支持模型映射模式,不支持白名单模式 -->
|
||||||
<div v-if="form.platform === 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
<div v-if="form.platform === 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||||
@ -3085,7 +3246,7 @@ interface TempUnschedRuleForm {
|
|||||||
// State
|
// State
|
||||||
const step = ref(1)
|
const step = ref(1)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock'>('oauth-based') // UI selection for account category
|
const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock' | 'service_account'>('oauth-based') // UI selection for account category
|
||||||
const addMethod = ref<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-token'
|
const addMethod = ref<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-token'
|
||||||
const apiKeyBaseUrl = ref('https://api.anthropic.com')
|
const apiKeyBaseUrl = ref('https://api.anthropic.com')
|
||||||
const apiKeyValue = ref('')
|
const apiKeyValue = ref('')
|
||||||
@ -3151,6 +3312,58 @@ const bedrockSessionToken = ref('')
|
|||||||
const bedrockRegion = ref('us-east-1')
|
const bedrockRegion = ref('us-east-1')
|
||||||
const bedrockForceGlobal = ref(false)
|
const bedrockForceGlobal = ref(false)
|
||||||
const bedrockApiKeyValue = ref('')
|
const bedrockApiKeyValue = ref('')
|
||||||
|
const vertexServiceAccountFileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const vertexServiceAccountJson = ref('')
|
||||||
|
const vertexProjectId = ref('')
|
||||||
|
const vertexClientEmail = ref('')
|
||||||
|
const vertexLocation = ref('global')
|
||||||
|
const vertexServiceAccountDragActive = ref(false)
|
||||||
|
const vertexLocationOptions = [
|
||||||
|
{
|
||||||
|
label: 'Common',
|
||||||
|
options: [
|
||||||
|
{ value: 'us-central1', label: 'us-central1 (Iowa)' },
|
||||||
|
{ value: 'global', label: 'global' },
|
||||||
|
{ value: 'us', label: 'us' },
|
||||||
|
{ value: 'eu', label: 'eu' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'United States',
|
||||||
|
options: [
|
||||||
|
{ value: 'us-east1', label: 'us-east1 (South Carolina)' },
|
||||||
|
{ value: 'us-east4', label: 'us-east4 (Northern Virginia)' },
|
||||||
|
{ value: 'us-east5', label: 'us-east5 (Columbus)' },
|
||||||
|
{ value: 'us-south1', label: 'us-south1 (Dallas)' },
|
||||||
|
{ value: 'us-west1', label: 'us-west1 (Oregon)' },
|
||||||
|
{ value: 'us-west4', label: 'us-west4 (Las Vegas)' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Europe',
|
||||||
|
options: [
|
||||||
|
{ value: 'europe-west1', label: 'europe-west1 (Belgium)' },
|
||||||
|
{ value: 'europe-west2', label: 'europe-west2 (London)' },
|
||||||
|
{ value: 'europe-west3', label: 'europe-west3 (Frankfurt)' },
|
||||||
|
{ value: 'europe-west4', label: 'europe-west4 (Netherlands)' },
|
||||||
|
{ value: 'europe-west6', label: 'europe-west6 (Zurich)' },
|
||||||
|
{ value: 'europe-west8', label: 'europe-west8 (Milan)' },
|
||||||
|
{ value: 'europe-west9', label: 'europe-west9 (Paris)' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Asia Pacific',
|
||||||
|
options: [
|
||||||
|
{ value: 'asia-east1', label: 'asia-east1 (Taiwan)' },
|
||||||
|
{ value: 'asia-east2', label: 'asia-east2 (Hong Kong)' },
|
||||||
|
{ value: 'asia-northeast1', label: 'asia-northeast1 (Tokyo)' },
|
||||||
|
{ value: 'asia-northeast3', label: 'asia-northeast3 (Seoul)' },
|
||||||
|
{ value: 'asia-south1', label: 'asia-south1 (Mumbai)' },
|
||||||
|
{ value: 'asia-southeast1', label: 'asia-southeast1 (Singapore)' },
|
||||||
|
{ value: 'australia-southeast1', label: 'australia-southeast1 (Sydney)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
] as const
|
||||||
const tempUnschedEnabled = ref(false)
|
const tempUnschedEnabled = ref(false)
|
||||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
|
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
|
||||||
@ -3397,7 +3610,7 @@ watch(
|
|||||||
|
|
||||||
// Sync form.type based on accountCategory, addMethod, and platform-specific type
|
// Sync form.type based on accountCategory, addMethod, and platform-specific type
|
||||||
watch(
|
watch(
|
||||||
[accountCategory, addMethod, antigravityAccountType],
|
[accountCategory, addMethod, antigravityAccountType, () => form.platform],
|
||||||
([category, method, agType]) => {
|
([category, method, agType]) => {
|
||||||
// Antigravity upstream 类型(实际创建为 apikey)
|
// Antigravity upstream 类型(实际创建为 apikey)
|
||||||
if (form.platform === 'antigravity' && agType === 'upstream') {
|
if (form.platform === 'antigravity' && agType === 'upstream') {
|
||||||
@ -3409,7 +3622,9 @@ watch(
|
|||||||
form.type = 'bedrock' as AccountType
|
form.type = 'bedrock' as AccountType
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (category === 'oauth-based') {
|
if ((form.platform === 'gemini' || form.platform === 'anthropic') && category === 'service_account') {
|
||||||
|
form.type = 'service_account' as AccountType
|
||||||
|
} else if (category === 'oauth-based') {
|
||||||
form.type = method as AccountType // 'oauth' or 'setup-token'
|
form.type = method as AccountType // 'oauth' or 'setup-token'
|
||||||
} else {
|
} else {
|
||||||
form.type = 'apikey'
|
form.type = 'apikey'
|
||||||
@ -3447,6 +3662,12 @@ watch(
|
|||||||
antigravityModelMappings.value = []
|
antigravityModelMappings.value = []
|
||||||
antigravityModelRestrictionMode.value = 'mapping'
|
antigravityModelRestrictionMode.value = 'mapping'
|
||||||
}
|
}
|
||||||
|
if (newPlatform !== 'gemini' && newPlatform !== 'anthropic' && accountCategory.value === 'service_account') {
|
||||||
|
accountCategory.value = 'oauth-based'
|
||||||
|
}
|
||||||
|
if (newPlatform !== 'anthropic' && accountCategory.value === 'bedrock') {
|
||||||
|
accountCategory.value = 'oauth-based'
|
||||||
|
}
|
||||||
// Reset Bedrock fields when switching platforms
|
// Reset Bedrock fields when switching platforms
|
||||||
bedrockAccessKeyId.value = ''
|
bedrockAccessKeyId.value = ''
|
||||||
bedrockSecretAccessKey.value = ''
|
bedrockSecretAccessKey.value = ''
|
||||||
@ -3455,6 +3676,10 @@ watch(
|
|||||||
bedrockForceGlobal.value = false
|
bedrockForceGlobal.value = false
|
||||||
bedrockAuthMode.value = 'sigv4'
|
bedrockAuthMode.value = 'sigv4'
|
||||||
bedrockApiKeyValue.value = ''
|
bedrockApiKeyValue.value = ''
|
||||||
|
vertexServiceAccountJson.value = ''
|
||||||
|
vertexProjectId.value = ''
|
||||||
|
vertexClientEmail.value = ''
|
||||||
|
vertexLocation.value = 'global'
|
||||||
// Reset Anthropic/Antigravity-specific settings when switching to other platforms
|
// Reset Anthropic/Antigravity-specific settings when switching to other platforms
|
||||||
if (newPlatform !== 'anthropic' && newPlatform !== 'antigravity') {
|
if (newPlatform !== 'anthropic' && newPlatform !== 'antigravity') {
|
||||||
interceptWarmupRequests.value = false
|
interceptWarmupRequests.value = false
|
||||||
@ -3886,6 +4111,10 @@ const resetForm = () => {
|
|||||||
antigravityAccountType.value = 'oauth'
|
antigravityAccountType.value = 'oauth'
|
||||||
upstreamBaseUrl.value = ''
|
upstreamBaseUrl.value = ''
|
||||||
upstreamApiKey.value = ''
|
upstreamApiKey.value = ''
|
||||||
|
vertexServiceAccountJson.value = ''
|
||||||
|
vertexProjectId.value = ''
|
||||||
|
vertexClientEmail.value = ''
|
||||||
|
vertexLocation.value = 'global'
|
||||||
tempUnschedEnabled.value = false
|
tempUnschedEnabled.value = false
|
||||||
tempUnschedRules.value = []
|
tempUnschedRules.value = []
|
||||||
geminiOAuthType.value = 'code_assist'
|
geminiOAuthType.value = 'code_assist'
|
||||||
@ -4009,6 +4238,52 @@ const normalizePoolModeRetryCount = (value: number) => {
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyVertexServiceAccountJson = (value: string) => {
|
||||||
|
const raw = value.trim()
|
||||||
|
if (!raw) {
|
||||||
|
vertexProjectId.value = ''
|
||||||
|
vertexClientEmail.value = ''
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||||
|
const projectId = typeof parsed.project_id === 'string' ? parsed.project_id.trim() : ''
|
||||||
|
const clientEmail = typeof parsed.client_email === 'string' ? parsed.client_email.trim() : ''
|
||||||
|
const privateKey = typeof parsed.private_key === 'string' ? parsed.private_key.trim() : ''
|
||||||
|
if (!projectId || !clientEmail || !privateKey) {
|
||||||
|
appStore.showError('Service Account JSON 缺少 project_id、client_email 或 private_key')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
vertexProjectId.value = projectId
|
||||||
|
vertexClientEmail.value = clientEmail
|
||||||
|
vertexServiceAccountJson.value = JSON.stringify(parsed)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
appStore.showError('Service Account JSON 格式无效')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseVertexServiceAccountJson = () => applyVertexServiceAccountJson(vertexServiceAccountJson.value)
|
||||||
|
|
||||||
|
const handleVertexServiceAccountFile = async (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
applyVertexServiceAccountJson(await file.text())
|
||||||
|
} finally {
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVertexServiceAccountDrop = async (event: DragEvent) => {
|
||||||
|
vertexServiceAccountDragActive.value = false
|
||||||
|
const file = event.dataTransfer?.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
applyVertexServiceAccountJson(await file.text())
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
// For OAuth-based type, handle OAuth flow (goes to step 2)
|
// For OAuth-based type, handle OAuth flow (goes to step 2)
|
||||||
if (isOAuthFlow.value) {
|
if (isOAuthFlow.value) {
|
||||||
@ -4122,6 +4397,29 @@ const handleSubmit = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((form.platform === 'gemini' || form.platform === 'anthropic') && accountCategory.value === 'service_account') {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!parseVertexServiceAccountJson()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!vertexLocation.value.trim()) {
|
||||||
|
appStore.showError('请填写 Vertex location')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const credentials: Record<string, unknown> = {
|
||||||
|
service_account_json: vertexServiceAccountJson.value.trim(),
|
||||||
|
project_id: vertexProjectId.value.trim(),
|
||||||
|
client_email: vertexClientEmail.value.trim(),
|
||||||
|
location: vertexLocation.value.trim(),
|
||||||
|
tier_id: 'vertex'
|
||||||
|
}
|
||||||
|
await createAccountAndFinish(form.platform, 'service_account' as AccountType, credentials)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// For apikey type, create directly
|
// For apikey type, create directly
|
||||||
if (!apiKeyValue.value.trim()) {
|
if (!apiKeyValue.value.trim()) {
|
||||||
appStore.showError(t('admin.accounts.pleaseEnterApiKey'))
|
appStore.showError(t('admin.accounts.pleaseEnterApiKey'))
|
||||||
|
|||||||
@ -567,6 +567,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Vertex Service Account -->
|
||||||
|
<div v-if="(account.platform === 'gemini' || account.platform === 'anthropic') && account.type === 'service_account'" class="space-y-4">
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="input-label">Project ID</label>
|
||||||
|
<input
|
||||||
|
v-model="editVertexProjectId"
|
||||||
|
type="text"
|
||||||
|
class="input font-mono"
|
||||||
|
readonly
|
||||||
|
placeholder="从 JSON 自动读取"
|
||||||
|
/>
|
||||||
|
<p class="input-hint">Service Account JSON 不在编辑页显示;需要更换 JSON 时请删除账号后重新创建。</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="input-label">Location</label>
|
||||||
|
<select
|
||||||
|
v-model="editVertexLocation"
|
||||||
|
required
|
||||||
|
class="input font-mono"
|
||||||
|
>
|
||||||
|
<optgroup
|
||||||
|
v-for="group in vertexLocationOptions"
|
||||||
|
: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">不同 Vertex 模型可用 location 可能不同,这里选择账号默认 endpoint location。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Bedrock fields (for bedrock type, both SigV4 and API Key modes) -->
|
<!-- Bedrock fields (for bedrock type, both SigV4 and API Key modes) -->
|
||||||
<div v-if="account.type === 'bedrock'" class="space-y-4">
|
<div v-if="account.type === 'bedrock'" class="space-y-4">
|
||||||
<!-- SigV4 fields -->
|
<!-- SigV4 fields -->
|
||||||
@ -1987,6 +2027,55 @@ const editBedrockSessionToken = ref('')
|
|||||||
const editBedrockRegion = ref('')
|
const editBedrockRegion = ref('')
|
||||||
const editBedrockForceGlobal = ref(false)
|
const editBedrockForceGlobal = ref(false)
|
||||||
const editBedrockApiKeyValue = ref('')
|
const editBedrockApiKeyValue = ref('')
|
||||||
|
const editVertexProjectId = ref('')
|
||||||
|
const editVertexClientEmail = ref('')
|
||||||
|
const editVertexLocation = ref('us-central1')
|
||||||
|
const vertexLocationOptions = [
|
||||||
|
{
|
||||||
|
label: 'Common',
|
||||||
|
options: [
|
||||||
|
{ value: 'us-central1', label: 'us-central1 (Iowa)' },
|
||||||
|
{ value: 'global', label: 'global' },
|
||||||
|
{ value: 'us', label: 'us' },
|
||||||
|
{ value: 'eu', label: 'eu' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'United States',
|
||||||
|
options: [
|
||||||
|
{ value: 'us-east1', label: 'us-east1 (South Carolina)' },
|
||||||
|
{ value: 'us-east4', label: 'us-east4 (Northern Virginia)' },
|
||||||
|
{ value: 'us-east5', label: 'us-east5 (Columbus)' },
|
||||||
|
{ value: 'us-south1', label: 'us-south1 (Dallas)' },
|
||||||
|
{ value: 'us-west1', label: 'us-west1 (Oregon)' },
|
||||||
|
{ value: 'us-west4', label: 'us-west4 (Las Vegas)' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Europe',
|
||||||
|
options: [
|
||||||
|
{ value: 'europe-west1', label: 'europe-west1 (Belgium)' },
|
||||||
|
{ value: 'europe-west2', label: 'europe-west2 (London)' },
|
||||||
|
{ value: 'europe-west3', label: 'europe-west3 (Frankfurt)' },
|
||||||
|
{ value: 'europe-west4', label: 'europe-west4 (Netherlands)' },
|
||||||
|
{ value: 'europe-west6', label: 'europe-west6 (Zurich)' },
|
||||||
|
{ value: 'europe-west8', label: 'europe-west8 (Milan)' },
|
||||||
|
{ value: 'europe-west9', label: 'europe-west9 (Paris)' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Asia Pacific',
|
||||||
|
options: [
|
||||||
|
{ value: 'asia-east1', label: 'asia-east1 (Taiwan)' },
|
||||||
|
{ value: 'asia-east2', label: 'asia-east2 (Hong Kong)' },
|
||||||
|
{ value: 'asia-northeast1', label: 'asia-northeast1 (Tokyo)' },
|
||||||
|
{ value: 'asia-northeast3', label: 'asia-northeast3 (Seoul)' },
|
||||||
|
{ value: 'asia-south1', label: 'asia-south1 (Mumbai)' },
|
||||||
|
{ value: 'asia-southeast1', label: 'asia-southeast1 (Singapore)' },
|
||||||
|
{ value: 'australia-southeast1', label: 'australia-southeast1 (Sydney)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
] as const
|
||||||
const isBedrockAPIKeyMode = computed(() =>
|
const isBedrockAPIKeyMode = computed(() =>
|
||||||
props.account?.type === 'bedrock' &&
|
props.account?.type === 'bedrock' &&
|
||||||
(props.account?.credentials as Record<string, unknown>)?.auth_mode === 'apikey'
|
(props.account?.credentials as Record<string, unknown>)?.auth_mode === 'apikey'
|
||||||
@ -2246,6 +2335,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
|||||||
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
||||||
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
|
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
|
||||||
autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true
|
autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true
|
||||||
|
editVertexProjectId.value = ''
|
||||||
|
editVertexClientEmail.value = ''
|
||||||
|
editVertexLocation.value = 'us-central1'
|
||||||
|
|
||||||
// Load mixed scheduling setting (only for antigravity accounts)
|
// Load mixed scheduling setting (only for antigravity accounts)
|
||||||
mixedScheduling.value = false
|
mixedScheduling.value = false
|
||||||
@ -2467,6 +2559,11 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
|||||||
} else if (newAccount.type === 'upstream' && newAccount.credentials) {
|
} else if (newAccount.type === 'upstream' && newAccount.credentials) {
|
||||||
const credentials = newAccount.credentials as Record<string, unknown>
|
const credentials = newAccount.credentials as Record<string, unknown>
|
||||||
editBaseUrl.value = (credentials.base_url as string) || ''
|
editBaseUrl.value = (credentials.base_url as string) || ''
|
||||||
|
} else if ((newAccount.platform === 'gemini' || newAccount.platform === 'anthropic') && newAccount.type === 'service_account' && newAccount.credentials) {
|
||||||
|
const credentials = newAccount.credentials as Record<string, unknown>
|
||||||
|
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'
|
||||||
} else {
|
} else {
|
||||||
const platformDefaultUrl =
|
const platformDefaultUrl =
|
||||||
newAccount.platform === 'openai'
|
newAccount.platform === 'openai'
|
||||||
@ -3057,6 +3154,38 @@ const handleSubmit = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePayload.credentials = newCredentials
|
||||||
|
} 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('Service Account JSON 缺少 project_id')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!editVertexClientEmail.value.trim()) {
|
||||||
|
appStore.showError('Service Account JSON 缺少 client_email')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!editVertexLocation.value.trim()) {
|
||||||
|
appStore.showError('请填写 Vertex location')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentCredentials.service_account_json && !currentCredentials.service_account) {
|
||||||
|
appStore.showError('请上传 Service Account JSON')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newCredentials.project_id = editVertexProjectId.value.trim()
|
||||||
|
newCredentials.client_email = editVertexClientEmail.value.trim()
|
||||||
|
newCredentials.location = editVertexLocation.value.trim()
|
||||||
|
newCredentials.tier_id = 'vertex'
|
||||||
|
|
||||||
|
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
|
||||||
|
if (!applyTempUnschedConfig(newCredentials)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
updatePayload.credentials = newCredentials
|
updatePayload.credentials = newCredentials
|
||||||
} else if (props.account.type === 'bedrock') {
|
} else if (props.account.type === 'bedrock') {
|
||||||
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
||||||
|
|||||||
@ -57,6 +57,19 @@ function makeAccount(overrides: Partial<Account>): Account {
|
|||||||
describe('AccountUsageCell', () => {
|
describe('AccountUsageCell', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getUsage.mockReset()
|
getUsage.mockReset()
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation(() => ({
|
||||||
|
matches: true,
|
||||||
|
media: '(min-width: 768px)',
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Antigravity 图片用量会聚合新旧 image 模型', async () => {
|
it('Antigravity 图片用量会聚合新旧 image 模型', async () => {
|
||||||
@ -603,4 +616,43 @@ describe('AccountUsageCell', () => {
|
|||||||
|
|
||||||
expect(wrapper.text().trim()).toBe('-')
|
expect(wrapper.text().trim()).toBe('-')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Vertex 账号会在 Gemini 用量窗口里展示 today stats 徽章', async () => {
|
||||||
|
const wrapper = mount(AccountUsageCell, {
|
||||||
|
props: {
|
||||||
|
account: makeAccount({
|
||||||
|
id: 4001,
|
||||||
|
platform: 'gemini',
|
||||||
|
type: 'service_account',
|
||||||
|
credentials: {
|
||||||
|
tier_id: 'vertex',
|
||||||
|
project_id: 'vertex-proj',
|
||||||
|
client_email: 'svc@vertex-proj.iam.gserviceaccount.com',
|
||||||
|
location: 'global'
|
||||||
|
},
|
||||||
|
extra: {}
|
||||||
|
}),
|
||||||
|
todayStats: {
|
||||||
|
requests: 0,
|
||||||
|
tokens: 0,
|
||||||
|
cost: 0,
|
||||||
|
standard_cost: 0,
|
||||||
|
user_cost: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
UsageProgressBar: true,
|
||||||
|
AccountQuotaInfo: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('0 req')
|
||||||
|
expect(wrapper.text()).toContain('0')
|
||||||
|
expect(wrapper.text()).toContain('A $0.00')
|
||||||
|
expect(wrapper.text()).toContain('U $0.00')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
<!-- Setup Token icon -->
|
<!-- Setup Token icon -->
|
||||||
<Icon v-else-if="type === 'setup-token'" name="shield" size="xs" />
|
<Icon v-else-if="type === 'setup-token'" name="shield" size="xs" />
|
||||||
<!-- API Key icon -->
|
<!-- API Key icon -->
|
||||||
|
<Icon v-else-if="type === 'service_account'" name="cloud" size="xs" />
|
||||||
<Icon v-else name="key" size="xs" />
|
<Icon v-else name="key" size="xs" />
|
||||||
<span>{{ typeLabel }}</span>
|
<span>{{ typeLabel }}</span>
|
||||||
</span>
|
</span>
|
||||||
@ -88,6 +89,8 @@ const typeLabel = computed(() => {
|
|||||||
return 'Key'
|
return 'Key'
|
||||||
case 'bedrock':
|
case 'bedrock':
|
||||||
return 'AWS'
|
return 'AWS'
|
||||||
|
case 'service_account':
|
||||||
|
return 'Vertex'
|
||||||
default:
|
default:
|
||||||
return props.type
|
return props.type
|
||||||
}
|
}
|
||||||
|
|||||||
@ -643,7 +643,7 @@ export interface UpdateGroupRequest {
|
|||||||
// ==================== Account & Proxy Types ====================
|
// ==================== Account & Proxy Types ====================
|
||||||
|
|
||||||
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
||||||
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock'
|
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock' | 'service_account'
|
||||||
export type OAuthAddMethod = 'oauth' | 'setup-token'
|
export type OAuthAddMethod = 'oauth' | 'setup-token'
|
||||||
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'
|
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user