Commit 4644af2c authored by SsageParuders's avatar SsageParuders
Browse files

refactor: merge bedrock-apikey into bedrock with auth_mode credential

Consolidate two separate channel types (bedrock + bedrock-apikey) into
a single "AWS Bedrock" channel. Authentication mode is now distinguished
by credentials.auth_mode ("sigv4" | "apikey") instead of separate types.

Backend:
- Remove AccountTypeBedrockAPIKey constant
- IsBedrock() simplified; IsBedrockAPIKey() checks auth_mode
- Add IsAPIKeyOrBedrock() helper to eliminate repeated type checks
- Extend pool mode, quota scheduling, and billing to bedrock
- Add RetryableOnSameAccount to handleBedrockUpstreamErrors
- Add "bedrock" scope to Beta Policy for independent control

Frontend:
- Merge two buttons into one "AWS Bedrock" with auth mode radio
- Badge displays "Anthropic | AWS"
- Pool mode and quota limit UI available for bedrock
- Quota display in account list (usage bars, capacity badges, reset)
- Remove all bedrock-apikey type references
parent 2e3e8687
...@@ -31,8 +31,7 @@ const ( ...@@ -31,8 +31,7 @@ const (
AccountTypeSetupToken = "setup-token" // Setup Token类型账号(inference only scope) AccountTypeSetupToken = "setup-token" // Setup Token类型账号(inference only scope)
AccountTypeAPIKey = "apikey" // API Key类型账号 AccountTypeAPIKey = "apikey" // API Key类型账号
AccountTypeUpstream = "upstream" // 上游透传类型账号(通过 Base URL + API Key 连接上游) AccountTypeUpstream = "upstream" // 上游透传类型账号(通过 Base URL + API Key 连接上游)
AccountTypeBedrock = "bedrock" // AWS Bedrock 类型账号(通过 SigV4 签名连接 Bedrock) AccountTypeBedrock = "bedrock" // AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
AccountTypeBedrockAPIKey = "bedrock-apikey" // AWS Bedrock API Key 类型账号(通过 Bearer Token 连接 Bedrock)
) )
// Redeem type constants // Redeem type constants
......
...@@ -97,7 +97,7 @@ type CreateAccountRequest struct { ...@@ -97,7 +97,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 bedrock-apikey"` Type string `json:"type" binding:"required,oneof=oauth setup-token apikey upstream bedrock"`
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"`
...@@ -116,7 +116,7 @@ type CreateAccountRequest struct { ...@@ -116,7 +116,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 bedrock-apikey"` Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey upstream bedrock"`
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"`
......
...@@ -264,8 +264,8 @@ func AccountFromServiceShallow(a *service.Account) *Account { ...@@ -264,8 +264,8 @@ func AccountFromServiceShallow(a *service.Account) *Account {
} }
} }
// 提取 API Key 账号配额限制(apikey 类型有效) // 提取账号配额限制(apikey / bedrock 类型有效)
if a.Type == service.AccountTypeAPIKey { if a.IsAPIKeyOrBedrock() {
if limit := a.GetQuotaLimit(); limit > 0 { if limit := a.GetQuotaLimit(); limit > 0 {
out.QuotaLimit = &limit out.QuotaLimit = &limit
used := a.GetQuotaUsed() used := a.GetQuotaUsed()
......
...@@ -656,7 +656,7 @@ func (a *Account) IsCustomErrorCodesEnabled() bool { ...@@ -656,7 +656,7 @@ func (a *Account) IsCustomErrorCodesEnabled() bool {
// IsPoolMode 检查 API Key 账号是否启用池模式。 // IsPoolMode 检查 API Key 账号是否启用池模式。
// 池模式下,上游错误不标记本地账号状态,而是在同一账号上重试。 // 池模式下,上游错误不标记本地账号状态,而是在同一账号上重试。
func (a *Account) IsPoolMode() bool { func (a *Account) IsPoolMode() bool {
if a.Type != AccountTypeAPIKey || a.Credentials == nil { if !a.IsAPIKeyOrBedrock() || a.Credentials == nil {
return false return false
} }
if v, ok := a.Credentials["pool_mode"]; ok { if v, ok := a.Credentials["pool_mode"]; ok {
...@@ -771,11 +771,16 @@ func (a *Account) IsInterceptWarmupEnabled() bool { ...@@ -771,11 +771,16 @@ func (a *Account) IsInterceptWarmupEnabled() bool {
} }
func (a *Account) IsBedrock() bool { func (a *Account) IsBedrock() bool {
return a.Platform == PlatformAnthropic && (a.Type == AccountTypeBedrock || a.Type == AccountTypeBedrockAPIKey) return a.Platform == PlatformAnthropic && a.Type == AccountTypeBedrock
} }
func (a *Account) IsBedrockAPIKey() bool { func (a *Account) IsBedrockAPIKey() bool {
return a.Platform == PlatformAnthropic && a.Type == AccountTypeBedrockAPIKey return a.IsBedrock() && a.GetCredential("auth_mode") == "apikey"
}
// IsAPIKeyOrBedrock 返回账号类型是否支持配额和池模式等特性
func (a *Account) IsAPIKeyOrBedrock() bool {
return a.Type == AccountTypeAPIKey || a.Type == AccountTypeBedrock
} }
func (a *Account) IsOpenAI() bool { func (a *Account) IsOpenAI() bool {
......
...@@ -33,8 +33,7 @@ const ( ...@@ -33,8 +33,7 @@ const (
AccountTypeSetupToken = domain.AccountTypeSetupToken // Setup Token类型账号(inference only scope) AccountTypeSetupToken = domain.AccountTypeSetupToken // Setup Token类型账号(inference only scope)
AccountTypeAPIKey = domain.AccountTypeAPIKey // API Key类型账号 AccountTypeAPIKey = domain.AccountTypeAPIKey // API Key类型账号
AccountTypeUpstream = domain.AccountTypeUpstream // 上游透传类型账号(通过 Base URL + API Key 连接上游) AccountTypeUpstream = domain.AccountTypeUpstream // 上游透传类型账号(通过 Base URL + API Key 连接上游)
AccountTypeBedrock = domain.AccountTypeBedrock // AWS Bedrock 类型账号(通过 SigV4 签名连接 Bedrock) AccountTypeBedrock = domain.AccountTypeBedrock // AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
AccountTypeBedrockAPIKey = domain.AccountTypeBedrockAPIKey // AWS Bedrock API Key 类型账号(通过 Bearer Token 连接 Bedrock)
) )
// Redeem type constants // Redeem type constants
......
...@@ -2173,10 +2173,10 @@ func (s *GatewayService) withWindowCostPrefetch(ctx context.Context, accounts [] ...@@ -2173,10 +2173,10 @@ func (s *GatewayService) withWindowCostPrefetch(ctx context.Context, accounts []
return context.WithValue(ctx, windowCostPrefetchContextKey, costs) return context.WithValue(ctx, windowCostPrefetchContextKey, costs)
} }
// isAccountSchedulableForQuota 检查 API Key 账号是否在配额限制内 // isAccountSchedulableForQuota 检查账号是否在配额限制内
// 适用于配置了 quota_limit 的 apikey 类型账号 // 适用于配置了 quota_limit 的 apikey 和 bedrock 类型账号
func (s *GatewayService) isAccountSchedulableForQuota(account *Account) bool { func (s *GatewayService) isAccountSchedulableForQuota(account *Account) bool {
if account.Type != AccountTypeAPIKey { if !account.IsAPIKeyOrBedrock() {
return true return true
} }
return !account.IsQuotaExceeded() return !account.IsQuotaExceeded()
...@@ -3532,9 +3532,7 @@ func (s *GatewayService) GetAccessToken(ctx context.Context, account *Account) ( ...@@ -3532,9 +3532,7 @@ 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 签名,不需要 token return "", "bedrock", nil // Bedrock 使用 SigV4 签名或 API Key,由 forwardBedrock 处理
case AccountTypeBedrockAPIKey:
return "", "bedrock-apikey", nil // Bedrock API Key 使用 Bearer Token,由 forwardBedrock 处理
default: default:
return "", "", fmt.Errorf("unsupported account type: %s", account.Type) return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
} }
...@@ -5186,7 +5184,7 @@ func (s *GatewayService) forwardBedrock( ...@@ -5186,7 +5184,7 @@ func (s *GatewayService) forwardBedrock(
if account.IsBedrockAPIKey() { if account.IsBedrockAPIKey() {
bedrockAPIKey = account.GetCredential("api_key") bedrockAPIKey = account.GetCredential("api_key")
if bedrockAPIKey == "" { if bedrockAPIKey == "" {
return nil, fmt.Errorf("api_key not found in bedrock-apikey credentials") return nil, fmt.Errorf("api_key not found in bedrock credentials")
} }
} else { } else {
signer, err = NewBedrockSignerFromAccount(account) signer, err = NewBedrockSignerFromAccount(account)
...@@ -5377,6 +5375,7 @@ func (s *GatewayService) handleBedrockUpstreamErrors( ...@@ -5377,6 +5375,7 @@ func (s *GatewayService) handleBedrockUpstreamErrors(
return nil, &UpstreamFailoverError{ return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode, StatusCode: resp.StatusCode,
ResponseBody: respBody, ResponseBody: respBody,
RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
} }
} }
return s.handleRetryExhaustedError(ctx, resp, c, account) return s.handleRetryExhaustedError(ctx, resp, c, account)
...@@ -5400,6 +5399,7 @@ func (s *GatewayService) handleBedrockUpstreamErrors( ...@@ -5400,6 +5399,7 @@ func (s *GatewayService) handleBedrockUpstreamErrors(
return nil, &UpstreamFailoverError{ return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode, StatusCode: resp.StatusCode,
ResponseBody: respBody, ResponseBody: respBody,
RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
} }
} }
...@@ -5808,9 +5808,10 @@ func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader stri ...@@ -5808,9 +5808,10 @@ func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader stri
return betaPolicyResult{} return betaPolicyResult{}
} }
isOAuth := account.IsOAuth() isOAuth := account.IsOAuth()
isBedrock := account.IsBedrock()
var result betaPolicyResult var result betaPolicyResult
for _, rule := range settings.Rules { for _, rule := range settings.Rules {
if !betaPolicyScopeMatches(rule.Scope, isOAuth) { if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
continue continue
} }
switch rule.Action { switch rule.Action {
...@@ -5870,14 +5871,16 @@ func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Cont ...@@ -5870,14 +5871,16 @@ func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Cont
} }
// betaPolicyScopeMatches checks whether a rule's scope matches the current account type. // betaPolicyScopeMatches checks whether a rule's scope matches the current account type.
func betaPolicyScopeMatches(scope string, isOAuth bool) bool { func betaPolicyScopeMatches(scope string, isOAuth bool, isBedrock bool) bool {
switch scope { switch scope {
case BetaPolicyScopeAll: case BetaPolicyScopeAll:
return true return true
case BetaPolicyScopeOAuth: case BetaPolicyScopeOAuth:
return isOAuth return isOAuth
case BetaPolicyScopeAPIKey: case BetaPolicyScopeAPIKey:
return !isOAuth return !isOAuth && !isBedrock
case BetaPolicyScopeBedrock:
return isBedrock
default: default:
return true // unknown scope → match all (fail-open) return true // unknown scope → match all (fail-open)
} }
...@@ -5959,12 +5962,13 @@ func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, toke ...@@ -5959,12 +5962,13 @@ func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, toke
return nil return nil
} }
isOAuth := account.IsOAuth() isOAuth := account.IsOAuth()
isBedrock := account.IsBedrock()
tokenSet := buildBetaTokenSet(tokens) tokenSet := buildBetaTokenSet(tokens)
for _, rule := range settings.Rules { for _, rule := range settings.Rules {
if rule.Action != BetaPolicyActionBlock { if rule.Action != BetaPolicyActionBlock {
continue continue
} }
if !betaPolicyScopeMatches(rule.Scope, isOAuth) { if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
continue continue
} }
if _, present := tokenSet[rule.BetaToken]; present { if _, present := tokenSet[rule.BetaToken]; present {
...@@ -7176,7 +7180,7 @@ func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *bill ...@@ -7176,7 +7180,7 @@ func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *bill
} }
// 4. 账号配额用量(账号口径:TotalCost × 账号计费倍率) // 4. 账号配额用量(账号口径:TotalCost × 账号计费倍率)
if cost.TotalCost > 0 && p.Account.Type == AccountTypeAPIKey && p.Account.HasAnyQuotaLimit() { if cost.TotalCost > 0 && p.Account.IsAPIKeyOrBedrock() && p.Account.HasAnyQuotaLimit() {
accountCost := cost.TotalCost * p.AccountRateMultiplier accountCost := cost.TotalCost * p.AccountRateMultiplier
if err := deps.accountRepo.IncrementQuotaUsed(billingCtx, p.Account.ID, accountCost); err != nil { if err := deps.accountRepo.IncrementQuotaUsed(billingCtx, p.Account.ID, accountCost); err != nil {
slog.Error("increment account quota used failed", "account_id", p.Account.ID, "cost", accountCost, "error", err) slog.Error("increment account quota used failed", "account_id", p.Account.ID, "cost", accountCost, "error", err)
...@@ -7264,7 +7268,7 @@ func buildUsageBillingCommand(requestID string, usageLog *UsageLog, p *postUsage ...@@ -7264,7 +7268,7 @@ func buildUsageBillingCommand(requestID string, usageLog *UsageLog, p *postUsage
if p.Cost.ActualCost > 0 && p.APIKey.HasRateLimits() && p.APIKeyService != nil { if p.Cost.ActualCost > 0 && p.APIKey.HasRateLimits() && p.APIKeyService != nil {
cmd.APIKeyRateLimitCost = p.Cost.ActualCost cmd.APIKeyRateLimitCost = p.Cost.ActualCost
} }
if p.Cost.TotalCost > 0 && p.Account.Type == AccountTypeAPIKey && p.Account.HasAnyQuotaLimit() { if p.Cost.TotalCost > 0 && p.Account.IsAPIKeyOrBedrock() && p.Account.HasAnyQuotaLimit() {
cmd.AccountQuotaCost = p.Cost.TotalCost * p.AccountRateMultiplier cmd.AccountQuotaCost = p.Cost.TotalCost * p.AccountRateMultiplier
} }
......
...@@ -1278,7 +1278,7 @@ func (s *SettingService) SetBetaPolicySettings(ctx context.Context, settings *Be ...@@ -1278,7 +1278,7 @@ func (s *SettingService) SetBetaPolicySettings(ctx context.Context, settings *Be
BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true, BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true,
} }
validScopes := map[string]bool{ validScopes := map[string]bool{
BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true,
} }
for i, rule := range settings.Rules { for i, rule := range settings.Rules {
......
...@@ -201,13 +201,14 @@ const ( ...@@ -201,13 +201,14 @@ const (
BetaPolicyScopeAll = "all" // 所有账号类型 BetaPolicyScopeAll = "all" // 所有账号类型
BetaPolicyScopeOAuth = "oauth" // 仅 OAuth 账号 BetaPolicyScopeOAuth = "oauth" // 仅 OAuth 账号
BetaPolicyScopeAPIKey = "apikey" // 仅 API Key 账号 BetaPolicyScopeAPIKey = "apikey" // 仅 API Key 账号
BetaPolicyScopeBedrock = "bedrock" // 仅 AWS Bedrock 账号
) )
// BetaPolicyRule 单条 Beta 策略规则 // BetaPolicyRule 单条 Beta 策略规则
type BetaPolicyRule struct { type BetaPolicyRule struct {
BetaToken string `json:"beta_token"` // beta token 值 BetaToken string `json:"beta_token"` // beta token 值
Action string `json:"action"` // "pass" | "filter" | "block" Action string `json:"action"` // "pass" | "filter" | "block"
Scope string `json:"scope"` // "all" | "oauth" | "apikey" Scope string `json:"scope"` // "all" | "oauth" | "apikey" | "bedrock"
ErrorMessage string `json:"error_message,omitempty"` // 自定义错误消息 (action=block 时生效) ErrorMessage string `json:"error_message,omitempty"` // 自定义错误消息 (action=block 时生效)
} }
......
...@@ -316,7 +316,7 @@ export async function updateRectifierSettings( ...@@ -316,7 +316,7 @@ export async function updateRectifierSettings(
export interface BetaPolicyRule { export interface BetaPolicyRule {
beta_token: string beta_token: string
action: 'pass' | 'filter' | 'block' action: 'pass' | 'filter' | 'block'
scope: 'all' | 'oauth' | 'apikey' scope: 'all' | 'oauth' | 'apikey' | 'bedrock'
error_message?: string error_message?: string
} }
......
...@@ -292,17 +292,19 @@ const rpmTooltip = computed(() => { ...@@ -292,17 +292,19 @@ const rpmTooltip = computed(() => {
} }
}) })
// 是否显示各维度配额(仅 apikey 类型) // 是否显示各维度配额(apikey / bedrock 类型)
const isQuotaEligible = computed(() => props.account.type === 'apikey' || props.account.type === 'bedrock')
const showDailyQuota = computed(() => { const showDailyQuota = computed(() => {
return props.account.type === 'apikey' && (props.account.quota_daily_limit ?? 0) > 0 return isQuotaEligible.value && (props.account.quota_daily_limit ?? 0) > 0
}) })
const showWeeklyQuota = computed(() => { const showWeeklyQuota = computed(() => {
return props.account.type === 'apikey' && (props.account.quota_weekly_limit ?? 0) > 0 return isQuotaEligible.value && (props.account.quota_weekly_limit ?? 0) > 0
}) })
const showTotalQuota = computed(() => { const showTotalQuota = computed(() => {
return props.account.type === 'apikey' && (props.account.quota_limit ?? 0) > 0 return isQuotaEligible.value && (props.account.quota_limit ?? 0) > 0
}) })
// 格式化费用显示 // 格式化费用显示
......
...@@ -859,7 +859,7 @@ const makeQuotaBar = ( ...@@ -859,7 +859,7 @@ const makeQuotaBar = (
} }
const hasApiKeyQuota = computed(() => { const hasApiKeyQuota = computed(() => {
if (props.account.type !== 'apikey') return false if (props.account.type !== 'apikey' && props.account.type !== 'bedrock') return false
return ( return (
(props.account.quota_daily_limit ?? 0) > 0 || (props.account.quota_daily_limit ?? 0) > 0 ||
(props.account.quota_weekly_limit ?? 0) > 0 || (props.account.quota_weekly_limit ?? 0) > 0 ||
......
...@@ -323,35 +323,6 @@ ...@@ -323,35 +323,6 @@
</div> </div>
</button> </button>
<button
type="button"
@click="accountCategory = 'bedrock-apikey'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'bedrock-apikey'
? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20'
: 'border-gray-200 hover:border-amber-300 dark:border-dark-600 dark:hover:border-amber-700'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'bedrock-apikey'
? 'bg-amber-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="key" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{
t('admin.accounts.bedrockApiKeyLabel')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.bedrockApiKeyDesc')
}}</span>
</div>
</button>
</div> </div>
</div> </div>
...@@ -956,7 +927,7 @@ ...@@ -956,7 +927,7 @@
</div> </div>
<!-- API Key input (only for apikey type, excluding Antigravity which has its own fields) --> <!-- API Key input (only for apikey type, excluding Antigravity which has its own fields) -->
<div v-if="form.type === 'apikey' && form.platform !== 'antigravity' && accountCategory !== 'bedrock-apikey'" class="space-y-4"> <div v-if="form.type === 'apikey' && form.platform !== 'antigravity'" class="space-y-4">
<div> <div>
<label class="input-label">{{ t('admin.accounts.baseUrl') }}</label> <label class="input-label">{{ t('admin.accounts.baseUrl') }}</label>
<input <input
...@@ -1341,6 +1312,33 @@ ...@@ -1341,6 +1312,33 @@
<!-- Bedrock credentials (only for Anthropic Bedrock type) --> <!-- Bedrock credentials (only for Anthropic Bedrock type) -->
<div v-if="form.platform === 'anthropic' && accountCategory === 'bedrock'" class="space-y-4"> <div v-if="form.platform === 'anthropic' && accountCategory === 'bedrock'" class="space-y-4">
<!-- Auth Mode Radio -->
<div>
<label class="input-label">{{ t('admin.accounts.bedrockAuthMode') }}</label>
<div class="mt-2 flex gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="bedrockAuthMode"
type="radio"
value="sigv4"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.bedrockAuthModeSigv4') }}</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="bedrockAuthMode"
type="radio"
value="apikey"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.bedrockAuthModeApikey') }}</span>
</label>
</div>
</div>
<!-- SigV4 fields -->
<template v-if="bedrockAuthMode === 'sigv4'">
<div> <div>
<label class="input-label">{{ t('admin.accounts.bedrockAccessKeyId') }}</label> <label class="input-label">{{ t('admin.accounts.bedrockAccessKeyId') }}</label>
<input <input
...@@ -1369,6 +1367,20 @@ ...@@ -1369,6 +1367,20 @@
/> />
<p class="input-hint">{{ t('admin.accounts.bedrockSessionTokenHint') }}</p> <p class="input-hint">{{ t('admin.accounts.bedrockSessionTokenHint') }}</p>
</div> </div>
</template>
<!-- API Key field -->
<div v-if="bedrockAuthMode === 'apikey'">
<label class="input-label">{{ t('admin.accounts.bedrockApiKeyInput') }}</label>
<input
v-model="bedrockApiKeyValue"
type="password"
required
class="input font-mono"
/>
</div>
<!-- Shared: Region -->
<div> <div>
<label class="input-label">{{ t('admin.accounts.bedrockRegion') }}</label> <label class="input-label">{{ t('admin.accounts.bedrockRegion') }}</label>
<select v-model="bedrockRegion" class="input"> <select v-model="bedrockRegion" class="input">
...@@ -1408,6 +1420,8 @@ ...@@ -1408,6 +1420,8 @@
</select> </select>
<p class="input-hint">{{ t('admin.accounts.bedrockRegionHint') }}</p> <p class="input-hint">{{ t('admin.accounts.bedrockRegionHint') }}</p>
</div> </div>
<!-- Shared: Force Global -->
<div> <div>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input <input
...@@ -1488,142 +1502,62 @@ ...@@ -1488,142 +1502,62 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Bedrock API Key credentials (only for Anthropic Bedrock API Key type) --> <!-- Pool Mode Section for Bedrock -->
<div v-if="form.platform === 'anthropic' && accountCategory === 'bedrock-apikey'" class="space-y-4"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div> <div class="mb-3 flex items-center justify-between">
<label class="input-label">{{ t('admin.accounts.bedrockApiKeyInput') }}</label>
<input
v-model="bedrockApiKeyValue"
type="password"
required
class="input font-mono"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.bedrockRegion') }}</label>
<select v-model="bedrockApiKeyRegion" class="input">
<optgroup label="US">
<option value="us-east-1">us-east-1 (N. Virginia)</option>
<option value="us-east-2">us-east-2 (Ohio)</option>
<option value="us-west-1">us-west-1 (N. California)</option>
<option value="us-west-2">us-west-2 (Oregon)</option>
<option value="us-gov-east-1">us-gov-east-1 (GovCloud US-East)</option>
<option value="us-gov-west-1">us-gov-west-1 (GovCloud US-West)</option>
</optgroup>
<optgroup label="Europe">
<option value="eu-west-1">eu-west-1 (Ireland)</option>
<option value="eu-west-2">eu-west-2 (London)</option>
<option value="eu-west-3">eu-west-3 (Paris)</option>
<option value="eu-central-1">eu-central-1 (Frankfurt)</option>
<option value="eu-central-2">eu-central-2 (Zurich)</option>
<option value="eu-south-1">eu-south-1 (Milan)</option>
<option value="eu-south-2">eu-south-2 (Spain)</option>
<option value="eu-north-1">eu-north-1 (Stockholm)</option>
</optgroup>
<optgroup label="Asia Pacific">
<option value="ap-northeast-1">ap-northeast-1 (Tokyo)</option>
<option value="ap-northeast-2">ap-northeast-2 (Seoul)</option>
<option value="ap-northeast-3">ap-northeast-3 (Osaka)</option>
<option value="ap-south-1">ap-south-1 (Mumbai)</option>
<option value="ap-south-2">ap-south-2 (Hyderabad)</option>
<option value="ap-southeast-1">ap-southeast-1 (Singapore)</option>
<option value="ap-southeast-2">ap-southeast-2 (Sydney)</option>
</optgroup>
<optgroup label="Canada">
<option value="ca-central-1">ca-central-1 (Canada)</option>
</optgroup>
<optgroup label="South America">
<option value="sa-east-1">sa-east-1 (São Paulo)</option>
</optgroup>
</select>
<p class="input-hint">{{ t('admin.accounts.bedrockRegionHint') }}</p>
</div>
<div> <div>
<label class="flex items-center gap-2 cursor-pointer"> <label class="input-label mb-0">{{ t('admin.accounts.poolMode') }}</label>
<input <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
v-model="bedrockApiKeyForceGlobal" {{ t('admin.accounts.poolModeHint') }}
type="checkbox" </p>
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.bedrockForceGlobal') }}</span>
</label>
<p class="input-hint mt-1">{{ t('admin.accounts.bedrockForceGlobalHint') }}</p>
</div> </div>
<!-- Model Restriction Section for Bedrock API Key -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<!-- Mode Toggle -->
<div class="mb-4 flex gap-2">
<button <button
type="button" type="button"
@click="modelRestrictionMode = 'whitelist'" @click="poolModeEnabled = !poolModeEnabled"
:class="[ :class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all', 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
modelRestrictionMode === 'whitelist' poolModeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]" ]"
> >
{{ t('admin.accounts.modelWhitelist') }} <span
</button>
<button
type="button"
@click="modelRestrictionMode = 'mapping'"
:class="[ :class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all', 'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
modelRestrictionMode === 'mapping' poolModeEnabled ? 'translate-x-5' : 'translate-x-0'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]" ]"
> />
{{ t('admin.accounts.modelMapping') }}
</button> </button>
</div> </div>
<div v-if="poolModeEnabled" class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<!-- Whitelist Mode --> <p class="text-xs text-blue-700 dark:text-blue-400">
<div v-if="modelRestrictionMode === 'whitelist'"> <Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
<ModelWhitelistSelector v-model="allowedModels" platform="anthropic" /> {{ t('admin.accounts.poolModeInfo') }}
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
</p> </p>
</div> </div>
<div v-if="poolModeEnabled" class="mt-3">
<!-- Mapping Mode --> <label class="input-label">{{ t('admin.accounts.poolModeRetryCount') }}</label>
<div v-else class="space-y-3"> <input
<div v-for="(mapping, index) in modelMappings" :key="index" class="flex items-center gap-2"> v-model.number="poolModeRetryCount"
<input v-model="mapping.from" type="text" class="input flex-1" :placeholder="t('admin.accounts.fromModel')" /> type="number"
<span class="text-gray-400"></span> min="0"
<input v-model="mapping.to" type="text" class="input flex-1" :placeholder="t('admin.accounts.toModel')" /> :max="MAX_POOL_MODE_RETRY_COUNT"
<button type="button" @click="modelMappings.splice(index, 1)" class="text-red-500 hover:text-red-700"> step="1"
<Icon name="trash" size="sm" /> class="input"
</button> />
</div> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<button type="button" @click="modelMappings.push({ from: '', to: '' })" class="btn btn-secondary text-sm"> {{
+ {{ t('admin.accounts.addMapping') }} t('admin.accounts.poolModeRetryCountHint', {
</button> default: DEFAULT_POOL_MODE_RETRY_COUNT,
<!-- Bedrock Preset Mappings --> max: MAX_POOL_MODE_RETRY_COUNT
<div class="flex flex-wrap gap-2"> })
<button }}
v-for="preset in bedrockPresets" </p>
:key="preset.from"
type="button"
@click="addPresetMapping(preset.from, preset.to)"
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
>
+ {{ preset.label }}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- API Key 账号配额限制 --> <!-- API Key / Bedrock 账号配额限制 -->
<div v-if="form.type === 'apikey'" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"> <div v-if="form.type === 'apikey' || form.type === 'bedrock'" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3"> <div class="mb-3">
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3> <h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
...@@ -3014,7 +2948,7 @@ interface TempUnschedRuleForm { ...@@ -3014,7 +2948,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' | 'bedrock-apikey'>('oauth-based') // UI selection for account category const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock'>('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('')
...@@ -3050,16 +2984,13 @@ const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('an ...@@ -3050,16 +2984,13 @@ const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('an
const bedrockPresets = computed(() => getPresetMappingsByPlatform('bedrock')) const bedrockPresets = computed(() => getPresetMappingsByPlatform('bedrock'))
// Bedrock credentials // Bedrock credentials
const bedrockAuthMode = ref<'sigv4' | 'apikey'>('sigv4')
const bedrockAccessKeyId = ref('') const bedrockAccessKeyId = ref('')
const bedrockSecretAccessKey = ref('') const bedrockSecretAccessKey = ref('')
const bedrockSessionToken = ref('') const bedrockSessionToken = ref('')
const bedrockRegion = ref('us-east-1') const bedrockRegion = ref('us-east-1')
const bedrockForceGlobal = ref(false) const bedrockForceGlobal = ref(false)
// Bedrock API Key credentials
const bedrockApiKeyValue = ref('') const bedrockApiKeyValue = ref('')
const bedrockApiKeyRegion = ref('us-east-1')
const bedrockApiKeyForceGlobal = ref(false)
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')
...@@ -3343,7 +3274,8 @@ watch( ...@@ -3343,7 +3274,8 @@ watch(
bedrockSessionToken.value = '' bedrockSessionToken.value = ''
bedrockRegion.value = 'us-east-1' bedrockRegion.value = 'us-east-1'
bedrockForceGlobal.value = false bedrockForceGlobal.value = false
bedrockApiKeyForceGlobal.value = false bedrockAuthMode.value = 'sigv4'
bedrockApiKeyValue.value = ''
// 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
...@@ -3919,6 +3851,13 @@ const handleSubmit = async () => { ...@@ -3919,6 +3851,13 @@ const handleSubmit = async () => {
appStore.showError(t('admin.accounts.pleaseEnterAccountName')) appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
return return
} }
const credentials: Record<string, unknown> = {
auth_mode: bedrockAuthMode.value,
aws_region: bedrockRegion.value.trim() || 'us-east-1',
}
if (bedrockAuthMode.value === 'sigv4') {
if (!bedrockAccessKeyId.value.trim()) { if (!bedrockAccessKeyId.value.trim()) {
appStore.showError(t('admin.accounts.bedrockAccessKeyIdRequired')) appStore.showError(t('admin.accounts.bedrockAccessKeyIdRequired'))
return return
...@@ -3927,53 +3866,20 @@ const handleSubmit = async () => { ...@@ -3927,53 +3866,20 @@ const handleSubmit = async () => {
appStore.showError(t('admin.accounts.bedrockSecretAccessKeyRequired')) appStore.showError(t('admin.accounts.bedrockSecretAccessKeyRequired'))
return return
} }
if (!bedrockRegion.value.trim()) { credentials.aws_access_key_id = bedrockAccessKeyId.value.trim()
appStore.showError(t('admin.accounts.bedrockRegionRequired')) credentials.aws_secret_access_key = bedrockSecretAccessKey.value.trim()
return
}
const credentials: Record<string, unknown> = {
aws_access_key_id: bedrockAccessKeyId.value.trim(),
aws_secret_access_key: bedrockSecretAccessKey.value.trim(),
aws_region: bedrockRegion.value.trim(),
}
if (bedrockSessionToken.value.trim()) { if (bedrockSessionToken.value.trim()) {
credentials.aws_session_token = bedrockSessionToken.value.trim() credentials.aws_session_token = bedrockSessionToken.value.trim()
} }
if (bedrockForceGlobal.value) { } else {
credentials.aws_force_global = 'true'
}
// Model mapping
const modelMapping = buildModelMappingObject(
modelRestrictionMode.value, allowedModels.value, modelMappings.value
)
if (modelMapping) {
credentials.model_mapping = modelMapping
}
applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
await createAccountAndFinish('anthropic', 'bedrock' as AccountType, credentials)
return
}
// For Bedrock API Key type, create directly
if (form.platform === 'anthropic' && accountCategory.value === 'bedrock-apikey') {
if (!form.name.trim()) {
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
return
}
if (!bedrockApiKeyValue.value.trim()) { if (!bedrockApiKeyValue.value.trim()) {
appStore.showError(t('admin.accounts.bedrockApiKeyRequired')) appStore.showError(t('admin.accounts.bedrockApiKeyRequired'))
return return
} }
credentials.api_key = bedrockApiKeyValue.value.trim()
const credentials: Record<string, unknown> = {
api_key: bedrockApiKeyValue.value.trim(),
aws_region: bedrockApiKeyRegion.value.trim() || 'us-east-1',
} }
if (bedrockApiKeyForceGlobal.value) {
if (bedrockForceGlobal.value) {
credentials.aws_force_global = 'true' credentials.aws_force_global = 'true'
} }
...@@ -3985,9 +3891,15 @@ const handleSubmit = async () => { ...@@ -3985,9 +3891,15 @@ const handleSubmit = async () => {
credentials.model_mapping = modelMapping credentials.model_mapping = modelMapping
} }
// Pool mode
if (poolModeEnabled.value) {
credentials.pool_mode = true
credentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
}
applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create') applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
await createAccountAndFinish('anthropic', 'bedrock-apikey' as AccountType, credentials) await createAccountAndFinish('anthropic', 'bedrock' as AccountType, credentials)
return return
} }
...@@ -4233,9 +4145,9 @@ const createAccountAndFinish = async ( ...@@ -4233,9 +4145,9 @@ const createAccountAndFinish = async (
if (!applyTempUnschedConfig(credentials)) { if (!applyTempUnschedConfig(credentials)) {
return return
} }
// Inject quota limits for apikey accounts // Inject quota limits for apikey/bedrock accounts
let finalExtra = extra let finalExtra = extra
if (type === 'apikey') { if (type === 'apikey' || type === 'bedrock') {
const quotaExtra: Record<string, unknown> = { ...(extra || {}) } const quotaExtra: Record<string, unknown> = { ...(extra || {}) }
if (editQuotaLimit.value != null && editQuotaLimit.value > 0) { if (editQuotaLimit.value != null && editQuotaLimit.value > 0) {
quotaExtra.quota_limit = editQuotaLimit.value quotaExtra.quota_limit = editQuotaLimit.value
......
...@@ -563,8 +563,10 @@ ...@@ -563,8 +563,10 @@
</div> </div>
</div> </div>
<!-- Bedrock fields (only for bedrock type) --> <!-- 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 -->
<template v-if="!isBedrockAPIKeyMode">
<div> <div>
<label class="input-label">{{ t('admin.accounts.bedrockAccessKeyId') }}</label> <label class="input-label">{{ t('admin.accounts.bedrockAccessKeyId') }}</label>
<input <input
...@@ -594,6 +596,21 @@ ...@@ -594,6 +596,21 @@
/> />
<p class="input-hint">{{ t('admin.accounts.bedrockSessionTokenHint') }}</p> <p class="input-hint">{{ t('admin.accounts.bedrockSessionTokenHint') }}</p>
</div> </div>
</template>
<!-- API Key field -->
<div v-if="isBedrockAPIKeyMode">
<label class="input-label">{{ t('admin.accounts.bedrockApiKeyInput') }}</label>
<input
v-model="editBedrockApiKeyValue"
type="password"
class="input font-mono"
:placeholder="t('admin.accounts.bedrockApiKeyLeaveEmpty')"
/>
<p class="input-hint">{{ t('admin.accounts.bedrockApiKeyLeaveEmpty') }}</p>
</div>
<!-- Shared: Region -->
<div> <div>
<label class="input-label">{{ t('admin.accounts.bedrockRegion') }}</label> <label class="input-label">{{ t('admin.accounts.bedrockRegion') }}</label>
<input <input
...@@ -604,6 +621,8 @@ ...@@ -604,6 +621,8 @@
/> />
<p class="input-hint">{{ t('admin.accounts.bedrockRegionHint') }}</p> <p class="input-hint">{{ t('admin.accounts.bedrockRegionHint') }}</p>
</div> </div>
<!-- Shared: Force Global -->
<div> <div>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input <input
...@@ -684,108 +703,56 @@ ...@@ -684,108 +703,56 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Bedrock API Key fields (only for bedrock-apikey type) --> <!-- Pool Mode Section for Bedrock -->
<div v-if="account.type === 'bedrock-apikey'" class="space-y-4"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div> <div class="mb-3 flex items-center justify-between">
<label class="input-label">{{ t('admin.accounts.bedrockApiKeyInput') }}</label>
<input
v-model="editBedrockApiKeyValue"
type="password"
class="input font-mono"
:placeholder="t('admin.accounts.bedrockApiKeyLeaveEmpty')"
/>
<p class="input-hint">{{ t('admin.accounts.bedrockApiKeyLeaveEmpty') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.bedrockRegion') }}</label>
<input
v-model="editBedrockApiKeyRegion"
type="text"
class="input"
placeholder="us-east-1"
/>
<p class="input-hint">{{ t('admin.accounts.bedrockRegionHint') }}</p>
</div>
<div> <div>
<label class="flex items-center gap-2 cursor-pointer"> <label class="input-label mb-0">{{ t('admin.accounts.poolMode') }}</label>
<input <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
v-model="editBedrockApiKeyForceGlobal" {{ t('admin.accounts.poolModeHint') }}
type="checkbox" </p>
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.bedrockForceGlobal') }}</span>
</label>
<p class="input-hint mt-1">{{ t('admin.accounts.bedrockForceGlobalHint') }}</p>
</div> </div>
<!-- Model Restriction for Bedrock API Key -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<!-- Mode Toggle -->
<div class="mb-4 flex gap-2">
<button <button
type="button" type="button"
@click="modelRestrictionMode = 'whitelist'" @click="poolModeEnabled = !poolModeEnabled"
:class="[ :class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all', 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
modelRestrictionMode === 'whitelist' poolModeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]" ]"
> >
{{ t('admin.accounts.modelWhitelist') }} <span
</button>
<button
type="button"
@click="modelRestrictionMode = 'mapping'"
:class="[ :class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all', 'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
modelRestrictionMode === 'mapping' poolModeEnabled ? 'translate-x-5' : 'translate-x-0'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]" ]"
> />
{{ t('admin.accounts.modelMapping') }}
</button> </button>
</div> </div>
<div v-if="poolModeEnabled" class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<!-- Whitelist Mode --> <p class="text-xs text-blue-700 dark:text-blue-400">
<div v-if="modelRestrictionMode === 'whitelist'"> <Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
<ModelWhitelistSelector v-model="allowedModels" platform="anthropic" /> {{ t('admin.accounts.poolModeInfo') }}
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
</p> </p>
</div> </div>
<div v-if="poolModeEnabled" class="mt-3">
<!-- Mapping Mode --> <label class="input-label">{{ t('admin.accounts.poolModeRetryCount') }}</label>
<div v-else class="space-y-3"> <input
<div v-for="(mapping, index) in modelMappings" :key="getModelMappingKey(mapping)" class="flex items-center gap-2"> v-model.number="poolModeRetryCount"
<input v-model="mapping.from" type="text" class="input flex-1" :placeholder="t('admin.accounts.fromModel')" /> type="number"
<span class="text-gray-400"></span> min="0"
<input v-model="mapping.to" type="text" class="input flex-1" :placeholder="t('admin.accounts.toModel')" /> :max="MAX_POOL_MODE_RETRY_COUNT"
<button type="button" @click="modelMappings.splice(index, 1)" class="text-red-500 hover:text-red-700"> step="1"
<Icon name="trash" size="sm" /> class="input"
</button> />
</div> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<button type="button" @click="modelMappings.push({ from: '', to: '' })" class="btn btn-secondary text-sm"> {{
+ {{ t('admin.accounts.addMapping') }} t('admin.accounts.poolModeRetryCountHint', {
</button> default: DEFAULT_POOL_MODE_RETRY_COUNT,
<!-- Bedrock Preset Mappings --> max: MAX_POOL_MODE_RETRY_COUNT
<div class="flex flex-wrap gap-2"> })
<button }}
v-for="preset in bedrockPresets" </p>
:key="preset.from"
type="button"
@click="modelMappings.push({ from: preset.from, to: preset.to })"
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
>
+ {{ preset.label }}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -1182,8 +1149,8 @@ ...@@ -1182,8 +1149,8 @@
</div> </div>
</div> </div>
<!-- API Key 账号配额限制 --> <!-- API Key / Bedrock 账号配额限制 -->
<div v-if="account?.type === 'apikey'" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"> <div v-if="account?.type === 'apikey' || account?.type === 'bedrock'" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3"> <div class="mb-3">
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3> <h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
...@@ -1781,11 +1748,11 @@ const editBedrockSecretAccessKey = ref('') ...@@ -1781,11 +1748,11 @@ const editBedrockSecretAccessKey = ref('')
const editBedrockSessionToken = ref('') const editBedrockSessionToken = ref('')
const editBedrockRegion = ref('') const editBedrockRegion = ref('')
const editBedrockForceGlobal = ref(false) const editBedrockForceGlobal = ref(false)
// Bedrock API Key credentials
const editBedrockApiKeyValue = ref('') const editBedrockApiKeyValue = ref('')
const editBedrockApiKeyRegion = ref('') const isBedrockAPIKeyMode = computed(() =>
const editBedrockApiKeyForceGlobal = ref(false) props.account?.type === 'bedrock' &&
(props.account?.credentials as Record<string, unknown>)?.auth_mode === 'apikey'
)
const modelMappings = ref<ModelMapping[]>([]) const modelMappings = ref<ModelMapping[]>([])
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([]) const allowedModels = ref<string[]>([])
...@@ -2026,8 +1993,8 @@ watch( ...@@ -2026,8 +1993,8 @@ watch(
anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true
} }
// Load quota limit for apikey accounts // Load quota limit for apikey/bedrock accounts (bedrock quota is also loaded in its own branch above)
if (newAccount.type === 'apikey') { if (newAccount.type === 'apikey' || newAccount.type === 'bedrock') {
const quotaVal = extra?.quota_limit as number | undefined const quotaVal = extra?.quota_limit as number | undefined
editQuotaLimit.value = (quotaVal && quotaVal > 0) ? quotaVal : null editQuotaLimit.value = (quotaVal && quotaVal > 0) ? quotaVal : null
const dailyVal = extra?.quota_daily_limit as number | undefined const dailyVal = extra?.quota_daily_limit as number | undefined
...@@ -2130,11 +2097,28 @@ watch( ...@@ -2130,11 +2097,28 @@ watch(
} }
} else if (newAccount.type === 'bedrock' && newAccount.credentials) { } else if (newAccount.type === 'bedrock' && newAccount.credentials) {
const bedrockCreds = newAccount.credentials as Record<string, unknown> const bedrockCreds = newAccount.credentials as Record<string, unknown>
editBedrockAccessKeyId.value = (bedrockCreds.aws_access_key_id as string) || '' const authMode = (bedrockCreds.auth_mode as string) || 'sigv4'
editBedrockRegion.value = (bedrockCreds.aws_region as string) || '' editBedrockRegion.value = (bedrockCreds.aws_region as string) || ''
editBedrockForceGlobal.value = (bedrockCreds.aws_force_global as string) === 'true' editBedrockForceGlobal.value = (bedrockCreds.aws_force_global as string) === 'true'
if (authMode === 'apikey') {
editBedrockApiKeyValue.value = ''
} else {
editBedrockAccessKeyId.value = (bedrockCreds.aws_access_key_id as string) || ''
editBedrockSecretAccessKey.value = '' editBedrockSecretAccessKey.value = ''
editBedrockSessionToken.value = '' editBedrockSessionToken.value = ''
}
// Load pool mode for bedrock
poolModeEnabled.value = bedrockCreds.pool_mode === true
const retryCount = bedrockCreds.pool_mode_retry_count
poolModeRetryCount.value = (typeof retryCount === 'number' && retryCount >= 0) ? retryCount : DEFAULT_POOL_MODE_RETRY_COUNT
// Load quota limits for bedrock
const bedrockExtra = (newAccount.extra as Record<string, unknown>) || {}
editQuotaLimit.value = typeof bedrockExtra.quota_limit === 'number' ? bedrockExtra.quota_limit : null
editQuotaDailyLimit.value = typeof bedrockExtra.quota_daily_limit === 'number' ? bedrockExtra.quota_daily_limit : null
editQuotaWeeklyLimit.value = typeof bedrockExtra.quota_weekly_limit === 'number' ? bedrockExtra.quota_weekly_limit : null
// Load model mappings for bedrock // Load model mappings for bedrock
const existingMappings = bedrockCreds.model_mapping as Record<string, string> | undefined const existingMappings = bedrockCreds.model_mapping as Record<string, string> | undefined
...@@ -2155,31 +2139,6 @@ watch( ...@@ -2155,31 +2139,6 @@ watch(
modelMappings.value = [] modelMappings.value = []
allowedModels.value = [] allowedModels.value = []
} }
} else if (newAccount.type === 'bedrock-apikey' && newAccount.credentials) {
const bedrockApiKeyCreds = newAccount.credentials as Record<string, unknown>
editBedrockApiKeyRegion.value = (bedrockApiKeyCreds.aws_region as string) || 'us-east-1'
editBedrockApiKeyForceGlobal.value = (bedrockApiKeyCreds.aws_force_global as string) === 'true'
editBedrockApiKeyValue.value = ''
// Load model mappings for bedrock-apikey
const existingMappings = bedrockApiKeyCreds.model_mapping as Record<string, string> | undefined
if (existingMappings && typeof existingMappings === 'object') {
const entries = Object.entries(existingMappings)
const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
if (isWhitelistMode) {
modelRestrictionMode.value = 'whitelist'
allowedModels.value = entries.map(([from]) => from)
modelMappings.value = []
} else {
modelRestrictionMode.value = 'mapping'
modelMappings.value = entries.map(([from, to]) => ({ from, to }))
allowedModels.value = []
}
} else {
modelRestrictionMode.value = 'whitelist'
modelMappings.value = []
allowedModels.value = []
}
} 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) || ''
...@@ -2727,7 +2686,6 @@ const handleSubmit = async () => { ...@@ -2727,7 +2686,6 @@ const handleSubmit = async () => {
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {} const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
const newCredentials: Record<string, unknown> = { ...currentCredentials } const newCredentials: Record<string, unknown> = { ...currentCredentials }
newCredentials.aws_access_key_id = editBedrockAccessKeyId.value.trim()
newCredentials.aws_region = editBedrockRegion.value.trim() newCredentials.aws_region = editBedrockRegion.value.trim()
if (editBedrockForceGlobal.value) { if (editBedrockForceGlobal.value) {
newCredentials.aws_force_global = 'true' newCredentials.aws_force_global = 'true'
...@@ -2735,42 +2693,29 @@ const handleSubmit = async () => { ...@@ -2735,42 +2693,29 @@ const handleSubmit = async () => {
delete newCredentials.aws_force_global delete newCredentials.aws_force_global
} }
// Only update secrets if user provided new values if (isBedrockAPIKeyMode.value) {
// API Key mode: only update api_key if user provided new value
if (editBedrockApiKeyValue.value.trim()) {
newCredentials.api_key = editBedrockApiKeyValue.value.trim()
}
} else {
// SigV4 mode
newCredentials.aws_access_key_id = editBedrockAccessKeyId.value.trim()
if (editBedrockSecretAccessKey.value.trim()) { if (editBedrockSecretAccessKey.value.trim()) {
newCredentials.aws_secret_access_key = editBedrockSecretAccessKey.value.trim() newCredentials.aws_secret_access_key = editBedrockSecretAccessKey.value.trim()
} }
if (editBedrockSessionToken.value.trim()) { if (editBedrockSessionToken.value.trim()) {
newCredentials.aws_session_token = editBedrockSessionToken.value.trim() newCredentials.aws_session_token = editBedrockSessionToken.value.trim()
} }
// Model mapping
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
if (modelMapping) {
newCredentials.model_mapping = modelMapping
} else {
delete newCredentials.model_mapping
}
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit')
if (!applyTempUnschedConfig(newCredentials)) {
return
} }
updatePayload.credentials = newCredentials // Pool mode
} else if (props.account.type === 'bedrock-apikey') { if (poolModeEnabled.value) {
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {} newCredentials.pool_mode = true
const newCredentials: Record<string, unknown> = { ...currentCredentials } newCredentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
newCredentials.aws_region = editBedrockApiKeyRegion.value.trim() || 'us-east-1'
if (editBedrockApiKeyForceGlobal.value) {
newCredentials.aws_force_global = 'true'
} else { } else {
delete newCredentials.aws_force_global delete newCredentials.pool_mode
} delete newCredentials.pool_mode_retry_count
// Only update API key if user provided new value
if (editBedrockApiKeyValue.value.trim()) {
newCredentials.api_key = editBedrockApiKeyValue.value.trim()
} }
// Model mapping // Model mapping
...@@ -2980,8 +2925,8 @@ const handleSubmit = async () => { ...@@ -2980,8 +2925,8 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra updatePayload.extra = newExtra
} }
// For apikey accounts, handle quota_limit in extra // For apikey/bedrock accounts, handle quota_limit in extra
if (props.account.type === 'apikey') { if (props.account.type === 'apikey' || props.account.type === 'bedrock') {
const currentExtra = (updatePayload.extra as Record<string, unknown>) || const currentExtra = (updatePayload.extra as Record<string, unknown>) ||
(props.account.extra as Record<string, unknown>) || {} (props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra } const newExtra: Record<string, unknown> = { ...currentExtra }
......
...@@ -76,7 +76,7 @@ const hasRecoverableState = computed(() => { ...@@ -76,7 +76,7 @@ const hasRecoverableState = computed(() => {
return props.account?.status === 'error' || Boolean(isRateLimited.value) || Boolean(isOverloaded.value) || Boolean(isTempUnschedulable.value) return props.account?.status === 'error' || Boolean(isRateLimited.value) || Boolean(isOverloaded.value) || Boolean(isTempUnschedulable.value)
}) })
const hasQuotaLimit = computed(() => { const hasQuotaLimit = computed(() => {
return props.account?.type === 'apikey' && ( return (props.account?.type === 'apikey' || props.account?.type === 'bedrock') && (
(props.account?.quota_limit ?? 0) > 0 || (props.account?.quota_limit ?? 0) > 0 ||
(props.account?.quota_daily_limit ?? 0) > 0 || (props.account?.quota_daily_limit ?? 0) > 0 ||
(props.account?.quota_weekly_limit ?? 0) > 0 (props.account?.quota_weekly_limit ?? 0) > 0
......
...@@ -83,7 +83,7 @@ const typeLabel = computed(() => { ...@@ -83,7 +83,7 @@ const typeLabel = computed(() => {
case 'apikey': case 'apikey':
return 'Key' return 'Key'
case 'bedrock': case 'bedrock':
return 'Bedrock' return 'AWS'
default: default:
return props.type return props.type
} }
......
...@@ -412,7 +412,7 @@ export function getPresetMappingsByPlatform(platform: string) { ...@@ -412,7 +412,7 @@ export function getPresetMappingsByPlatform(platform: string) {
if (platform === 'gemini') return geminiPresetMappings if (platform === 'gemini') return geminiPresetMappings
if (platform === 'sora') return soraPresetMappings if (platform === 'sora') return soraPresetMappings
if (platform === 'antigravity') return antigravityPresetMappings if (platform === 'antigravity') return antigravityPresetMappings
if (platform === 'bedrock' || platform === 'bedrock-apikey') return bedrockPresetMappings if (platform === 'bedrock') return bedrockPresetMappings
return anthropicPresetMappings return anthropicPresetMappings
} }
......
...@@ -1934,7 +1934,7 @@ export default { ...@@ -1934,7 +1934,7 @@ export default {
claudeCode: 'Claude Code', claudeCode: 'Claude Code',
claudeConsole: 'Claude Console', claudeConsole: 'Claude Console',
bedrockLabel: 'AWS Bedrock', bedrockLabel: 'AWS Bedrock',
bedrockDesc: 'SigV4 Signing', bedrockDesc: 'SigV4 / API Key',
oauthSetupToken: 'OAuth / Setup Token', oauthSetupToken: 'OAuth / Setup Token',
addMethod: 'Add Method', addMethod: 'Add Method',
setupTokenLongLived: 'Setup Token (Long-lived)', setupTokenLongLived: 'Setup Token (Long-lived)',
...@@ -2136,6 +2136,9 @@ export default { ...@@ -2136,6 +2136,9 @@ export default {
bedrockRegionRequired: 'Please select AWS Region', bedrockRegionRequired: 'Please select AWS Region',
bedrockSessionTokenHint: 'Optional, for temporary credentials', bedrockSessionTokenHint: 'Optional, for temporary credentials',
bedrockSecretKeyLeaveEmpty: 'Leave empty to keep current key', bedrockSecretKeyLeaveEmpty: 'Leave empty to keep current key',
bedrockAuthMode: 'Authentication Mode',
bedrockAuthModeSigv4: 'SigV4 Signing',
bedrockAuthModeApikey: 'Bedrock API Key',
bedrockApiKeyLabel: 'Bedrock API Key', bedrockApiKeyLabel: 'Bedrock API Key',
bedrockApiKeyDesc: 'Bearer Token', bedrockApiKeyDesc: 'Bearer Token',
bedrockApiKeyInput: 'API Key', bedrockApiKeyInput: 'API Key',
...@@ -4127,6 +4130,7 @@ export default { ...@@ -4127,6 +4130,7 @@ export default {
scopeAll: 'All accounts', scopeAll: 'All accounts',
scopeOAuth: 'OAuth only', scopeOAuth: 'OAuth only',
scopeAPIKey: 'API Key only', scopeAPIKey: 'API Key only',
scopeBedrock: 'Bedrock only',
errorMessage: 'Error message', errorMessage: 'Error message',
errorMessagePlaceholder: 'Custom error message when blocked', errorMessagePlaceholder: 'Custom error message when blocked',
errorMessageHint: 'Leave empty for default message', errorMessageHint: 'Leave empty for default message',
......
...@@ -2082,7 +2082,7 @@ export default { ...@@ -2082,7 +2082,7 @@ export default {
claudeCode: 'Claude Code', claudeCode: 'Claude Code',
claudeConsole: 'Claude Console', claudeConsole: 'Claude Console',
bedrockLabel: 'AWS Bedrock', bedrockLabel: 'AWS Bedrock',
bedrockDesc: 'SigV4 签名', bedrockDesc: 'SigV4 / API Key',
oauthSetupToken: 'OAuth / Setup Token', oauthSetupToken: 'OAuth / Setup Token',
addMethod: '添加方式', addMethod: '添加方式',
setupTokenLongLived: 'Setup Token(长期有效)', setupTokenLongLived: 'Setup Token(长期有效)',
...@@ -2277,6 +2277,9 @@ export default { ...@@ -2277,6 +2277,9 @@ export default {
bedrockRegionRequired: '请选择 AWS Region', bedrockRegionRequired: '请选择 AWS Region',
bedrockSessionTokenHint: '可选,用于临时凭证', bedrockSessionTokenHint: '可选,用于临时凭证',
bedrockSecretKeyLeaveEmpty: '留空以保持当前密钥', bedrockSecretKeyLeaveEmpty: '留空以保持当前密钥',
bedrockAuthMode: '认证方式',
bedrockAuthModeSigv4: 'SigV4 签名',
bedrockAuthModeApikey: 'Bedrock API Key',
bedrockApiKeyLabel: 'Bedrock API Key', bedrockApiKeyLabel: 'Bedrock API Key',
bedrockApiKeyDesc: 'Bearer Token 认证', bedrockApiKeyDesc: 'Bearer Token 认证',
bedrockApiKeyInput: 'API Key', bedrockApiKeyInput: 'API Key',
...@@ -4300,6 +4303,7 @@ export default { ...@@ -4300,6 +4303,7 @@ export default {
scopeAll: '全部账号', scopeAll: '全部账号',
scopeOAuth: '仅 OAuth 账号', scopeOAuth: '仅 OAuth 账号',
scopeAPIKey: '仅 API Key 账号', scopeAPIKey: '仅 API Key 账号',
scopeBedrock: '仅 Bedrock 账号',
errorMessage: '错误消息', errorMessage: '错误消息',
errorMessagePlaceholder: '拦截时返回的自定义错误消息', errorMessagePlaceholder: '拦截时返回的自定义错误消息',
errorMessageHint: '留空则使用默认错误消息', errorMessageHint: '留空则使用默认错误消息',
......
...@@ -531,7 +531,7 @@ export interface UpdateGroupRequest { ...@@ -531,7 +531,7 @@ export interface UpdateGroupRequest {
// ==================== Account & Proxy Types ==================== // ==================== Account & Proxy Types ====================
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora' export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora'
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock' | 'bedrock-apikey' export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock'
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'
......
...@@ -1745,7 +1745,7 @@ const betaPolicyForm = reactive({ ...@@ -1745,7 +1745,7 @@ const betaPolicyForm = reactive({
rules: [] as Array<{ rules: [] as Array<{
beta_token: string beta_token: string
action: 'pass' | 'filter' | 'block' action: 'pass' | 'filter' | 'block'
scope: 'all' | 'oauth' | 'apikey' scope: 'all' | 'oauth' | 'apikey' | 'bedrock'
error_message?: string error_message?: string
}> }>
}) })
...@@ -2297,7 +2297,8 @@ const betaPolicyActionOptions = computed(() => [ ...@@ -2297,7 +2297,8 @@ const betaPolicyActionOptions = computed(() => [
const betaPolicyScopeOptions = computed(() => [ const betaPolicyScopeOptions = computed(() => [
{ value: 'all', label: t('admin.settings.betaPolicy.scopeAll') }, { value: 'all', label: t('admin.settings.betaPolicy.scopeAll') },
{ value: 'oauth', label: t('admin.settings.betaPolicy.scopeOAuth') }, { value: 'oauth', label: t('admin.settings.betaPolicy.scopeOAuth') },
{ value: 'apikey', label: t('admin.settings.betaPolicy.scopeAPIKey') } { value: 'apikey', label: t('admin.settings.betaPolicy.scopeAPIKey') },
{ value: 'bedrock', label: t('admin.settings.betaPolicy.scopeBedrock') }
]) ])
// Beta Policy 方法 // Beta Policy 方法
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment