Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
6d11f9ed
"backend/internal/vscode:/vscode.git/clone" did not exist on "7ae378dee1024af85e4ee0d80ef8619f927277a6"
Commit
6d11f9ed
authored
Apr 25, 2026
by
Oliver
Browse files
Add Vertex service account support
parent
489a4d93
Changes
17
Show whitespace changes
Inline
Side-by-side
backend/cmd/server/wire_gen.go
View file @
6d11f9ed
...
@@ -145,13 +145,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
...
@@ -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) {
...
@@ -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
)
...
...
backend/internal/domain/constants.go
View file @
6d11f9ed
...
@@ -31,6 +31,7 @@ const (
...
@@ -31,6 +31,7 @@ const (
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
...
...
backend/internal/handler/admin/account_handler.go
View file @
6d11f9ed
...
@@ -98,7 +98,7 @@ type CreateAccountRequest struct {
...
@@ -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 {
...
@@ -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"`
...
...
backend/internal/service/account_test_service.go
View file @
6d11f9ed
...
@@ -64,6 +64,7 @@ func isOpenAIImageModel(model string) bool {
...
@@ -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 {
...
@@ -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(
...
@@ -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
...
@@ -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
...
@@ -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
...
@@ -711,8 +785,8 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
testModelID
=
geminicli
.
DefaultTestModel
testModelID
=
geminicli
.
DefaultTestModel
}
}
// For
API Key account
s with model mapping, map the model
// For
static upstream credential
s 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
...
@@ -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
...
@@ -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
...
...
backend/internal/service/claude_token_provider.go
View file @
6d11f9ed
...
@@ -17,7 +17,7 @@ const (
...
@@ -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
...
@@ -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
...
@@ -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
}
backend/internal/service/claude_token_provider_test.go
View file @
6d11f9ed
...
@@ -137,7 +137,7 @@ func (p *testClaudeTokenProvider) GetAccessToken(ctx context.Context, account *A
...
@@ -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) {
...
@@ -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) {
...
@@ -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) {
...
@@ -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
)
}
}
...
...
backend/internal/service/domain_constants.go
View file @
6d11f9ed
...
@@ -41,6 +41,7 @@ const (
...
@@ -41,6 +41,7 @@ const (
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
...
...
backend/internal/service/gateway_anthropic_vertex_service_account_test.go
0 → 100644
View file @
6d11f9ed
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
}
backend/internal/service/gateway_service.go
View file @
6d11f9ed
...
@@ -3597,8 +3597,12 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo
...
@@ -3597,8 +3597,12 @@ 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
{
if
account
.
Type
==
AccountTypeServiceAccount
{
requestedModel
=
normalizeVertexAnthropicModelID
(
claude
.
NormalizeModelID
(
requestedModel
))
}
else
{
requestedModel
=
claude
.
NormalizeModelID
(
requestedModel
)
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) (
...
@@ -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
...
@@ -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(
...
@@ -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
...
@@ -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
{
...
...
backend/internal/service/gemini_messages_compat_service.go
View file @
6d11f9ed
...
@@ -579,7 +579,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
...
@@ -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
...
@@ -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.
...
@@ -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.
...
@@ -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
)
}
}
...
...
backend/internal/service/gemini_token_provider.go
View file @
6d11f9ed
...
@@ -15,7 +15,7 @@ const (
...
@@ -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
...
@@ -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
...
@@ -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
...
...
backend/internal/service/vertex_service_account.go
0 → 100644
View file @
6d11f9ed
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
)
}
backend/internal/service/vertex_service_account_test.go
0 → 100644
View file @
6d11f9ed
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"
))
}
frontend/src/components/account/CreateAccountModal.vue
View file @
6d11f9ed
...
@@ -153,7 +153,7 @@
...
@@ -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 @@
...
@@ -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 @@
...
@@ -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 @@
...
@@ -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 @@
...
@@ -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 @@
...
@@ -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 @@
...
@@ -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 @@
...
@@ -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 {
...
@@ -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('')
...
@@ -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(
...
@@ -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(
...
@@ -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(
...
@@ -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(
...
@@ -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 = () => {
...
@@ -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) => {
...
@@ -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 () => {
...
@@ -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
'
))
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
6d11f9ed
...
@@ -567,6 +567,46 @@
...
@@ -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('')
...
@@ -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) => {
...
@@ -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) => {
...
@@ -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 () => {
...
@@ -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
>
)
||
{
}
...
...
frontend/src/components/common/PlatformTypeBadge.vue
View file @
6d11f9ed
...
@@ -25,6 +25,7 @@
...
@@ -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(() => {
...
@@ -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
}
}
...
...
frontend/src/types/index.ts
View file @
6d11f9ed
...
@@ -641,7 +641,7 @@ export interface UpdateGroupRequest {
...
@@ -641,7 +641,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
'
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment