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
Commit
6d11f9ed
authored
Apr 25, 2026
by
Oliver
Browse files
Add Vertex service account support
parent
489a4d93
Changes
17
Hide 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) {
accountUsageService
:=
service
.
NewAccountUsageService
(
accountRepository
,
usageLogRepository
,
claudeUsageFetcher
,
geminiQuotaService
,
antigravityQuotaFetcher
,
usageCache
,
identityCache
,
tlsFingerprintProfileService
)
oAuthRefreshAPI
:=
service
.
ProvideOAuthRefreshAPI
(
accountRepository
,
geminiTokenCache
)
geminiTokenProvider
:=
service
.
ProvideGeminiTokenProvider
(
accountRepository
,
geminiTokenCache
,
geminiOAuthService
,
oAuthRefreshAPI
)
claudeTokenProvider
:=
service
.
ProvideClaudeTokenProvider
(
accountRepository
,
geminiTokenCache
,
oAuthService
,
oAuthRefreshAPI
)
gatewayCache
:=
repository
.
NewGatewayCache
(
redisClient
)
schedulerOutboxRepository
:=
repository
.
NewSchedulerOutboxRepository
(
db
)
schedulerSnapshotService
:=
service
.
ProvideSchedulerSnapshotService
(
schedulerCache
,
schedulerOutboxRepository
,
accountRepository
,
groupRepository
,
configConfig
)
antigravityTokenProvider
:=
service
.
ProvideAntigravityTokenProvider
(
accountRepository
,
geminiTokenCache
,
antigravityOAuthService
,
oAuthRefreshAPI
,
tempUnschedCache
)
internal500CounterCache
:=
repository
.
NewInternal500CounterCache
(
redisClient
)
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
)
accountHandler
:=
admin
.
NewAccountHandler
(
adminService
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
,
rateLimitService
,
accountUsageService
,
accountTestService
,
concurrencyService
,
crsSyncService
,
sessionLimitCache
,
rpmCache
,
compositeTokenCacheInvalidator
)
adminAnnouncementHandler
:=
admin
.
NewAnnouncementHandler
(
announcementService
)
...
...
@@ -178,7 +179,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
billingService
:=
service
.
NewBillingService
(
configConfig
,
pricingService
)
identityService
:=
service
.
NewIdentityService
(
identityCache
)
deferredService
:=
service
.
ProvideDeferredService
(
accountRepository
,
timingWheelService
)
claudeTokenProvider
:=
service
.
ProvideClaudeTokenProvider
(
accountRepository
,
geminiTokenCache
,
oAuthService
,
oAuthRefreshAPI
)
digestSessionStore
:=
service
.
NewDigestSessionStore
()
channelRepository
:=
repository
.
NewChannelRepository
(
db
)
channelService
:=
service
.
NewChannelService
(
channelRepository
,
groupRepository
,
apiKeyAuthCacheInvalidator
,
pricingService
)
...
...
backend/internal/domain/constants.go
View file @
6d11f9ed
...
...
@@ -26,11 +26,12 @@ const (
// Account type constants
const
(
AccountTypeOAuth
=
"oauth"
// OAuth类型账号(full scope: profile + inference)
AccountTypeSetupToken
=
"setup-token"
// Setup Token类型账号(inference only scope)
AccountTypeAPIKey
=
"apikey"
// API Key类型账号
AccountTypeUpstream
=
"upstream"
// 上游透传类型账号(通过 Base URL + API Key 连接上游)
AccountTypeBedrock
=
"bedrock"
// AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
AccountTypeOAuth
=
"oauth"
// OAuth类型账号(full scope: profile + inference)
AccountTypeSetupToken
=
"setup-token"
// Setup Token类型账号(inference only scope)
AccountTypeAPIKey
=
"apikey"
// API Key类型账号
AccountTypeUpstream
=
"upstream"
// 上游透传类型账号(通过 Base URL + API Key 连接上游)
AccountTypeBedrock
=
"bedrock"
// AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
AccountTypeServiceAccount
=
"service_account"
// Google Service Account 类型账号(用于 Vertex AI)
)
// Redeem type constants
...
...
backend/internal/handler/admin/account_handler.go
View file @
6d11f9ed
...
...
@@ -98,7 +98,7 @@ type CreateAccountRequest struct {
Name
string
`json:"name" binding:"required"`
Notes
*
string
`json:"notes"`
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"`
Extra
map
[
string
]
any
`json:"extra"`
ProxyID
*
int64
`json:"proxy_id"`
...
...
@@ -117,7 +117,7 @@ type CreateAccountRequest struct {
type
UpdateAccountRequest
struct
{
Name
string
`json:"name"`
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"`
Extra
map
[
string
]
any
`json:"extra"`
ProxyID
*
int64
`json:"proxy_id"`
...
...
backend/internal/service/account_test_service.go
View file @
6d11f9ed
...
...
@@ -64,6 +64,7 @@ func isOpenAIImageModel(model string) bool {
type
AccountTestService
struct
{
accountRepo
AccountRepository
geminiTokenProvider
*
GeminiTokenProvider
claudeTokenProvider
*
ClaudeTokenProvider
antigravityGatewayService
*
AntigravityGatewayService
httpUpstream
HTTPUpstream
cfg
*
config
.
Config
...
...
@@ -74,6 +75,7 @@ type AccountTestService struct {
func
NewAccountTestService
(
accountRepo
AccountRepository
,
geminiTokenProvider
*
GeminiTokenProvider
,
claudeTokenProvider
*
ClaudeTokenProvider
,
antigravityGatewayService
*
AntigravityGatewayService
,
httpUpstream
HTTPUpstream
,
cfg
*
config
.
Config
,
...
...
@@ -82,6 +84,7 @@ func NewAccountTestService(
return
&
AccountTestService
{
accountRepo
:
accountRepo
,
geminiTokenProvider
:
geminiTokenProvider
,
claudeTokenProvider
:
claudeTokenProvider
,
antigravityGatewayService
:
antigravityGatewayService
,
httpUpstream
:
httpUpstream
,
cfg
:
cfg
,
...
...
@@ -210,6 +213,9 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
if
account
.
IsBedrock
()
{
return
s
.
testBedrockAccountConnection
(
c
,
ctx
,
account
,
testModelID
)
}
if
account
.
Type
==
AccountTypeServiceAccount
{
return
s
.
testClaudeVertexServiceAccountConnection
(
c
,
ctx
,
account
,
testModelID
)
}
// Determine authentication method and API URL
var
authToken
string
...
...
@@ -313,6 +319,74 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
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
func
(
s
*
AccountTestService
)
testBedrockAccountConnection
(
c
*
gin
.
Context
,
ctx
context
.
Context
,
account
*
Account
,
testModelID
string
)
error
{
region
:=
bedrockRuntimeRegion
(
account
)
...
...
@@ -711,8 +785,8 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
testModelID
=
geminicli
.
DefaultTestModel
}
// For
API Key account
s with model mapping, map the model
if
account
.
Type
==
AccountTypeAPIKey
{
// For
static upstream credential
s with model mapping, map the model
if
account
.
Type
==
AccountTypeAPIKey
||
account
.
Type
==
AccountTypeServiceAccount
{
mapping
:=
account
.
GetModelMapping
()
if
len
(
mapping
)
>
0
{
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
)
case
AccountTypeOAuth
:
req
,
err
=
s
.
buildGeminiOAuthRequest
(
ctx
,
account
,
testModelID
,
payload
)
case
AccountTypeServiceAccount
:
req
,
err
=
s
.
buildGeminiServiceAccountRequest
(
ctx
,
account
,
testModelID
,
payload
)
default
:
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
)
}
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)
func
(
s
*
AccountTestService
)
buildCodeAssistRequest
(
ctx
context
.
Context
,
accessToken
,
projectID
,
modelID
string
,
payload
[]
byte
)
(
*
http
.
Request
,
error
)
{
var
inner
map
[
string
]
any
...
...
backend/internal/service/claude_token_provider.go
View file @
6d11f9ed
...
...
@@ -17,7 +17,7 @@ const (
// ClaudeTokenCache token cache interface.
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
{
accountRepo
AccountRepository
tokenCache
ClaudeTokenCache
...
...
@@ -56,8 +56,11 @@ func (p *ClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Accou
if
account
==
nil
{
return
""
,
errors
.
New
(
"account is nil"
)
}
if
account
.
Platform
!=
PlatformAnthropic
||
account
.
Type
!=
AccountTypeOAuth
{
return
""
,
errors
.
New
(
"not an anthropic oauth account"
)
if
account
.
Platform
!=
PlatformAnthropic
||
(
account
.
Type
!=
AccountTypeOAuth
&&
account
.
Type
!=
AccountTypeServiceAccount
)
{
return
""
,
errors
.
New
(
"not an anthropic oauth or service account"
)
}
if
account
.
Type
==
AccountTypeServiceAccount
{
return
p
.
getServiceAccountAccessToken
(
ctx
,
account
)
}
cacheKey
:=
ClaudeTokenCacheKey
(
account
)
...
...
@@ -157,3 +160,42 @@ func (p *ClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Accou
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
return
""
,
errors
.
New
(
"account is nil"
)
}
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
)
...
...
@@ -371,7 +371,7 @@ func TestClaudeTokenProvider_WrongPlatform(t *testing.T) {
token
,
err
:=
provider
.
GetAccessToken
(
context
.
Background
(),
account
)
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
)
}
...
...
@@ -385,7 +385,7 @@ func TestClaudeTokenProvider_WrongAccountType(t *testing.T) {
token
,
err
:=
provider
.
GetAccessToken
(
context
.
Background
(),
account
)
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
)
}
...
...
@@ -399,7 +399,7 @@ func TestClaudeTokenProvider_SetupTokenType(t *testing.T) {
token
,
err
:=
provider
.
GetAccessToken
(
context
.
Background
(),
account
)
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
)
}
...
...
backend/internal/service/domain_constants.go
View file @
6d11f9ed
...
...
@@ -36,11 +36,12 @@ const (
// Account type constants
const
(
AccountTypeOAuth
=
domain
.
AccountTypeOAuth
// OAuth类型账号(full scope: profile + inference)
AccountTypeSetupToken
=
domain
.
AccountTypeSetupToken
// Setup Token类型账号(inference only scope)
AccountTypeAPIKey
=
domain
.
AccountTypeAPIKey
// API Key类型账号
AccountTypeUpstream
=
domain
.
AccountTypeUpstream
// 上游透传类型账号(通过 Base URL + API Key 连接上游)
AccountTypeBedrock
=
domain
.
AccountTypeBedrock
// AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
AccountTypeOAuth
=
domain
.
AccountTypeOAuth
// OAuth类型账号(full scope: profile + inference)
AccountTypeSetupToken
=
domain
.
AccountTypeSetupToken
// Setup Token类型账号(inference only scope)
AccountTypeAPIKey
=
domain
.
AccountTypeAPIKey
// API Key类型账号
AccountTypeUpstream
=
domain
.
AccountTypeUpstream
// 上游透传类型账号(通过 Base URL + API Key 连接上游)
AccountTypeBedrock
=
domain
.
AccountTypeBedrock
// AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
AccountTypeServiceAccount
=
domain
.
AccountTypeServiceAccount
// Google Service Account 类型账号(用于 Vertex AI)
)
// 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,7 +3597,11 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo
}
// OAuth/SetupToken 账号使用 Anthropic 标准映射(短ID → 长ID)
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
)
...
...
@@ -3617,6 +3621,18 @@ func (s *GatewayService) GetAccessToken(ctx context.Context, account *Account) (
return
apiKey
,
"apikey"
,
nil
case
AccountTypeBedrock
:
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
:
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"
}
}
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
{
normalized
:=
claude
.
NormalizeModelID
(
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
)
{
if
account
.
Platform
==
PlatformAnthropic
&&
account
.
Type
==
AccountTypeServiceAccount
{
return
s
.
buildUpstreamRequestAnthropicVertex
(
ctx
,
c
,
account
,
body
,
token
,
modelID
,
reqStream
)
}
// 确定目标URL
targetURL
:=
claudeAPIURL
if
account
.
Type
==
AccountTypeAPIKey
{
...
...
@@ -5874,6 +5906,60 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
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
// 对于OAuth账号,需要确保包含oauth-2025-04-20
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
originalModel
:=
req
.
Model
mappedModel
:=
req
.
Model
if
account
.
Type
==
AccountTypeAPIKey
{
if
account
.
Type
==
AccountTypeAPIKey
||
account
.
Type
==
AccountTypeServiceAccount
{
mappedModel
=
account
.
GetMappedModel
(
req
.
Model
)
}
...
...
@@ -712,6 +712,36 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
}
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
:
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
)
mappedModel
:=
originalModel
if
account
.
Type
==
AccountTypeAPIKey
{
if
account
.
Type
==
AccountTypeAPIKey
||
account
.
Type
==
AccountTypeServiceAccount
{
mappedModel
=
account
.
GetMappedModel
(
originalModel
)
}
...
...
@@ -1213,6 +1243,31 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
}
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
:
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 (
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
{
accountRepo
AccountRepository
tokenCache
GeminiTokenCache
...
...
@@ -53,8 +53,11 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
if
account
==
nil
{
return
""
,
errors
.
New
(
"account is nil"
)
}
if
account
.
Platform
!=
PlatformGemini
||
account
.
Type
!=
AccountTypeOAuth
{
return
""
,
errors
.
New
(
"not a gemini oauth account"
)
if
account
.
Platform
!=
PlatformGemini
||
(
account
.
Type
!=
AccountTypeOAuth
&&
account
.
Type
!=
AccountTypeServiceAccount
)
{
return
""
,
errors
.
New
(
"not a gemini oauth or service account"
)
}
if
account
.
Type
==
AccountTypeServiceAccount
{
return
p
.
getServiceAccountAccessToken
(
ctx
,
account
)
}
cacheKey
:=
GeminiTokenCacheKey
(
account
)
...
...
@@ -168,7 +171,51 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
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
{
if
account
!=
nil
&&
account
.
Type
==
AccountTypeServiceAccount
{
if
key
,
err
:=
parseVertexServiceAccountKey
(
account
);
err
==
nil
{
return
vertexServiceAccountCacheKey
(
account
,
key
)
}
}
projectID
:=
strings
.
TrimSpace
(
account
.
GetCredential
(
"project_id"
))
if
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 @@
<!-- Account Type Selection (Anthropic) -->
<div
v-if=
"form.platform === 'anthropic'"
>
<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
type=
"button"
@
click=
"accountCategory = 'oauth-based'"
...
...
@@ -244,6 +244,39 @@
</div>
</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>
...
...
@@ -302,6 +335,7 @@
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.types.responsesApi
'
)
}}
</span>
</div>
</button>
</div>
</div>
...
...
@@ -320,7 +354,7 @@
{{
t
(
'
admin.accounts.gemini.helpButton
'
)
}}
</button>
</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
type=
"button"
@
click=
"accountCategory = 'oauth-based'"
...
...
@@ -392,6 +426,36 @@
</span>
</div>
</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
...
...
@@ -411,6 +475,13 @@
</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) -->
<div
v-if=
"accountCategory === 'oauth-based'"
class=
"mt-4"
>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.oauth.gemini.oauthTypeLabel
'
)
}}
</label>
...
...
@@ -610,7 +681,7 @@
</div>
<!-- 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>
<div
class=
"mt-2"
>
<select
...
...
@@ -729,6 +800,96 @@
</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 只支持模型映射模式,不支持白名单模式 -->
<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
const
step
=
ref
(
1
)
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
apiKeyBaseUrl
=
ref
(
'
https://api.anthropic.com
'
)
const
apiKeyValue
=
ref
(
''
)
...
...
@@ -3151,6 +3312,58 @@ const bedrockSessionToken = ref('')
const
bedrockRegion
=
ref
(
'
us-east-1
'
)
const
bedrockForceGlobal
=
ref
(
false
)
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
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
const
getModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
create-model-mapping
'
)
...
...
@@ -3397,7 +3610,7 @@ watch(
// Sync form.type based on accountCategory, addMethod, and platform-specific type
watch
(
[
accountCategory
,
addMethod
,
antigravityAccountType
],
[
accountCategory
,
addMethod
,
antigravityAccountType
,
()
=>
form
.
platform
],
([
category
,
method
,
agType
])
=>
{
// Antigravity upstream 类型(实际创建为 apikey)
if
(
form
.
platform
===
'
antigravity
'
&&
agType
===
'
upstream
'
)
{
...
...
@@ -3409,7 +3622,9 @@ watch(
form
.
type
=
'
bedrock
'
as
AccountType
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'
}
else
{
form
.
type
=
'
apikey
'
...
...
@@ -3447,6 +3662,12 @@ watch(
antigravityModelMappings
.
value
=
[]
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
bedrockAccessKeyId
.
value
=
''
bedrockSecretAccessKey
.
value
=
''
...
...
@@ -3455,6 +3676,10 @@ watch(
bedrockForceGlobal
.
value
=
false
bedrockAuthMode
.
value
=
'
sigv4
'
bedrockApiKeyValue
.
value
=
''
vertexServiceAccountJson
.
value
=
''
vertexProjectId
.
value
=
''
vertexClientEmail
.
value
=
''
vertexLocation
.
value
=
'
global
'
// Reset Anthropic/Antigravity-specific settings when switching to other platforms
if
(
newPlatform
!==
'
anthropic
'
&&
newPlatform
!==
'
antigravity
'
)
{
interceptWarmupRequests
.
value
=
false
...
...
@@ -3886,6 +4111,10 @@ const resetForm = () => {
antigravityAccountType
.
value
=
'
oauth
'
upstreamBaseUrl
.
value
=
''
upstreamApiKey
.
value
=
''
vertexServiceAccountJson
.
value
=
''
vertexProjectId
.
value
=
''
vertexClientEmail
.
value
=
''
vertexLocation
.
value
=
'
global
'
tempUnschedEnabled
.
value
=
false
tempUnschedRules
.
value
=
[]
geminiOAuthType
.
value
=
'
code_assist
'
...
...
@@ -4009,6 +4238,52 @@ const normalizePoolModeRetryCount = (value: number) => {
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
()
=>
{
// For OAuth-based type, handle OAuth flow (goes to step 2)
if
(
isOAuthFlow
.
value
)
{
...
...
@@ -4122,6 +4397,29 @@ const handleSubmit = async () => {
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
if
(
!
apiKeyValue
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.pleaseEnterApiKey
'
))
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
6d11f9ed
...
...
@@ -567,6 +567,46 @@
<
/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
)
-->
<
div
v
-
if
=
"
account.type === 'bedrock'
"
class
=
"
space-y-4
"
>
<!--
SigV4
fields
-->
...
...
@@ -1987,6 +2027,55 @@ const editBedrockSessionToken = ref('')
const
editBedrockRegion
=
ref
(
''
)
const
editBedrockForceGlobal
=
ref
(
false
)
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
(()
=>
props
.
account
?.
type
===
'
bedrock
'
&&
(
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
interceptWarmupRequests
.
value
=
credentials
?.
intercept_warmup_requests
===
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)
mixedScheduling
.
value
=
false
...
...
@@ -2467,6 +2559,11 @@ const syncFormFromAccount = (newAccount: Account | null) => {
}
else
if
(
newAccount
.
type
===
'
upstream
'
&&
newAccount
.
credentials
)
{
const
credentials
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
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
{
const
platformDefaultUrl
=
newAccount
.
platform
===
'
openai
'
...
...
@@ -3057,6 +3154,38 @@ const handleSubmit = async () => {
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
}
else
if
(
props
.
account
.
type
===
'
bedrock
'
)
{
const
currentCredentials
=
(
props
.
account
.
credentials
as
Record
<
string
,
unknown
>
)
||
{
}
...
...
frontend/src/components/common/PlatformTypeBadge.vue
View file @
6d11f9ed
...
...
@@ -25,6 +25,7 @@
<!-- Setup Token icon -->
<Icon
v-else-if=
"type === 'setup-token'"
name=
"shield"
size=
"xs"
/>
<!-- API Key icon -->
<Icon
v-else-if=
"type === 'service_account'"
name=
"cloud"
size=
"xs"
/>
<Icon
v-else
name=
"key"
size=
"xs"
/>
<span>
{{
typeLabel
}}
</span>
</span>
...
...
@@ -88,6 +89,8 @@ const typeLabel = computed(() => {
return
'
Key
'
case
'
bedrock
'
:
return
'
AWS
'
case
'
service_account
'
:
return
'
Vertex
'
default
:
return
props
.
type
}
...
...
frontend/src/types/index.ts
View file @
6d11f9ed
...
...
@@ -641,7 +641,7 @@ export interface UpdateGroupRequest {
// ==================== Account & Proxy Types ====================
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
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