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
e6d59216
Unverified
Commit
e6d59216
authored
Mar 14, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 14, 2026
Browse files
Merge pull request #975 from Ylarod/aws-bedrock
sub2api: add bedrock support
parents
4588258d
e90ec847
Changes
23
Show whitespace changes
Inline
Side-by-side
backend/go.mod
View file @
e6d59216
...
...
@@ -7,7 +7,7 @@ require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/DouDOU-start/go-sora2api v1.1.0
github.com/alitto/pond/v2 v2.6.2
github.com/aws/aws-sdk-go-v2 v1.41.
2
github.com/aws/aws-sdk-go-v2 v1.41.
3
github.com/aws/aws-sdk-go-v2/config v1.32.10
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2
...
...
@@ -66,7 +66,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
github.com/aws/smithy-go v1.24.
1
// indirect
github.com/aws/smithy-go v1.24.
2
// indirect
github.com/bdandy/go-errors v1.2.2 // indirect
github.com/bdandy/go-socks4 v1.2.3 // indirect
github.com/bmatcuk/doublestar v1.3.4 // indirect
...
...
backend/go.sum
View file @
e6d59216
...
...
@@ -24,6 +24,8 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
...
...
@@ -60,6 +62,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb8
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
...
...
backend/internal/domain/constants.go
View file @
e6d59216
...
...
@@ -31,6 +31,8 @@ const (
AccountTypeSetupToken
=
"setup-token"
// Setup Token类型账号(inference only scope)
AccountTypeAPIKey
=
"apikey"
// API Key类型账号
AccountTypeUpstream
=
"upstream"
// 上游透传类型账号(通过 Base URL + API Key 连接上游)
AccountTypeBedrock
=
"bedrock"
// AWS Bedrock 类型账号(通过 SigV4 签名连接 Bedrock)
AccountTypeBedrockAPIKey
=
"bedrock-apikey"
// AWS Bedrock API Key 类型账号(通过 Bearer Token 连接 Bedrock)
)
// Redeem type constants
...
...
@@ -113,3 +115,27 @@ var DefaultAntigravityModelMapping = map[string]string{
"gpt-oss-120b-medium"
:
"gpt-oss-120b-medium"
,
"tab_flash_lite_preview"
:
"tab_flash_lite_preview"
,
}
// DefaultBedrockModelMapping 是 AWS Bedrock 平台的默认模型映射
// 将 Anthropic 标准模型名映射到 Bedrock 模型 ID
// 注意:此处的 "us." 前缀仅为默认值,ResolveBedrockModelID 会根据账号配置的
// aws_region 自动调整为匹配的区域前缀(如 eu.、apac.、jp. 等)
var
DefaultBedrockModelMapping
=
map
[
string
]
string
{
// Claude Opus
"claude-opus-4-6-thinking"
:
"us.anthropic.claude-opus-4-6-v1"
,
"claude-opus-4-6"
:
"us.anthropic.claude-opus-4-6-v1"
,
"claude-opus-4-5-thinking"
:
"us.anthropic.claude-opus-4-5-20251101-v1:0"
,
"claude-opus-4-5-20251101"
:
"us.anthropic.claude-opus-4-5-20251101-v1:0"
,
"claude-opus-4-1"
:
"us.anthropic.claude-opus-4-1-20250805-v1:0"
,
"claude-opus-4-20250514"
:
"us.anthropic.claude-opus-4-20250514-v1:0"
,
// Claude Sonnet
"claude-sonnet-4-6-thinking"
:
"us.anthropic.claude-sonnet-4-6"
,
"claude-sonnet-4-6"
:
"us.anthropic.claude-sonnet-4-6"
,
"claude-sonnet-4-5"
:
"us.anthropic.claude-sonnet-4-5-20250929-v1:0"
,
"claude-sonnet-4-5-thinking"
:
"us.anthropic.claude-sonnet-4-5-20250929-v1:0"
,
"claude-sonnet-4-5-20250929"
:
"us.anthropic.claude-sonnet-4-5-20250929-v1:0"
,
"claude-sonnet-4-20250514"
:
"us.anthropic.claude-sonnet-4-20250514-v1:0"
,
// Claude Haiku
"claude-haiku-4-5"
:
"us.anthropic.claude-haiku-4-5-20251001-v1:0"
,
"claude-haiku-4-5-20251001"
:
"us.anthropic.claude-haiku-4-5-20251001-v1:0"
,
}
backend/internal/handler/admin/account_handler.go
View file @
e6d59216
...
...
@@ -97,7 +97,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"`
Type
string
`json:"type" binding:"required,oneof=oauth setup-token apikey upstream
bedrock bedrock-apikey
"`
Credentials
map
[
string
]
any
`json:"credentials" binding:"required"`
Extra
map
[
string
]
any
`json:"extra"`
ProxyID
*
int64
`json:"proxy_id"`
...
...
@@ -116,7 +116,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"`
Type
string
`json:"type" binding:"omitempty,oneof=oauth setup-token apikey upstream
bedrock bedrock-apikey
"`
Credentials
map
[
string
]
any
`json:"credentials"`
Extra
map
[
string
]
any
`json:"extra"`
ProxyID
*
int64
`json:"proxy_id"`
...
...
backend/internal/service/account.go
View file @
e6d59216
...
...
@@ -412,6 +412,7 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri
if
a
.
Platform
==
domain
.
PlatformAntigravity
{
return
domain
.
DefaultAntigravityModelMapping
}
// Bedrock 默认映射由 forwardBedrock 统一处理(需配合 region prefix 调整)
return
nil
}
if
len
(
rawMapping
)
==
0
{
...
...
@@ -764,6 +765,14 @@ func (a *Account) IsInterceptWarmupEnabled() bool {
return
false
}
func
(
a
*
Account
)
IsBedrock
()
bool
{
return
a
.
Platform
==
PlatformAnthropic
&&
(
a
.
Type
==
AccountTypeBedrock
||
a
.
Type
==
AccountTypeBedrockAPIKey
)
}
func
(
a
*
Account
)
IsBedrockAPIKey
()
bool
{
return
a
.
Platform
==
PlatformAnthropic
&&
a
.
Type
==
AccountTypeBedrockAPIKey
}
func
(
a
*
Account
)
IsOpenAI
()
bool
{
return
a
.
Platform
==
PlatformOpenAI
}
...
...
backend/internal/service/account_test_service.go
View file @
e6d59216
...
...
@@ -207,14 +207,14 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
testModelID
=
claude
.
DefaultTestModel
}
//
For
API Key
accounts with model mapping, map the model
// API Key
账号测试连接时也需要应用通配符模型映射。
if
account
.
Type
==
"apikey"
{
mapping
:=
account
.
GetModelMapping
()
if
len
(
mapping
)
>
0
{
if
mappedModel
,
exists
:=
mapping
[
testModelID
];
exists
{
testModelID
=
mappedModel
}
testModelID
=
account
.
GetMappedModel
(
testModelID
)
}
// Bedrock accounts use a separate test path
if
account
.
IsBedrock
()
{
return
s
.
testBedrockAccountConnection
(
c
,
ctx
,
account
,
testModelID
)
}
// Determine authentication method and API URL
...
...
@@ -312,6 +312,109 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
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
)
resolvedModelID
,
ok
:=
ResolveBedrockModelID
(
account
,
testModelID
)
if
!
ok
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Unsupported Bedrock model: %s"
,
testModelID
))
}
testModelID
=
resolvedModelID
// Set SSE headers (test UI expects SSE)
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
()
// Create a minimal Bedrock-compatible payload (no stream, no cache_control)
bedrockPayload
:=
map
[
string
]
any
{
"anthropic_version"
:
"bedrock-2023-05-31"
,
"messages"
:
[]
map
[
string
]
any
{
{
"role"
:
"user"
,
"content"
:
[]
map
[
string
]
any
{
{
"type"
:
"text"
,
"text"
:
"hi"
,
},
},
},
},
"max_tokens"
:
256
,
"temperature"
:
1
,
}
bedrockBody
,
_
:=
json
.
Marshal
(
bedrockPayload
)
// Use non-streaming endpoint (response is standard Claude JSON)
apiURL
:=
BuildBedrockURL
(
region
,
testModelID
,
false
)
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_start"
,
Model
:
testModelID
})
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
"POST"
,
apiURL
,
bytes
.
NewReader
(
bedrockBody
))
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
"Failed to create request"
)
}
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
// Sign or set auth based on account type
if
account
.
IsBedrockAPIKey
()
{
apiKey
:=
account
.
GetCredential
(
"api_key"
)
if
apiKey
==
""
{
return
s
.
sendErrorAndEnd
(
c
,
"No API key available"
)
}
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
apiKey
)
}
else
{
signer
,
err
:=
NewBedrockSignerFromAccount
(
account
)
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Failed to create Bedrock signer: %s"
,
err
.
Error
()))
}
if
err
:=
signer
.
SignRequest
(
ctx
,
req
,
bedrockBody
);
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Failed to sign request: %s"
,
err
.
Error
()))
}
}
proxyURL
:=
""
if
account
.
ProxyID
!=
nil
&&
account
.
Proxy
!=
nil
{
proxyURL
=
account
.
Proxy
.
URL
()
}
resp
,
err
:=
s
.
httpUpstream
.
DoWithTLS
(
req
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
false
)
if
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Request failed: %s"
,
err
.
Error
()))
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
body
,
_
:=
io
.
ReadAll
(
resp
.
Body
)
if
resp
.
StatusCode
!=
http
.
StatusOK
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"API returned %d: %s"
,
resp
.
StatusCode
,
string
(
body
)))
}
// Bedrock non-streaming response is standard Claude JSON, extract the text
var
result
struct
{
Content
[]
struct
{
Text
string
`json:"text"`
}
`json:"content"`
}
if
err
:=
json
.
Unmarshal
(
body
,
&
result
);
err
!=
nil
{
return
s
.
sendErrorAndEnd
(
c
,
fmt
.
Sprintf
(
"Failed to parse response: %s"
,
err
.
Error
()))
}
text
:=
""
if
len
(
result
.
Content
)
>
0
{
text
=
result
.
Content
[
0
]
.
Text
}
if
text
==
""
{
text
=
"(empty response)"
}
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
text
})
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"test_complete"
,
Success
:
true
})
return
nil
}
// testOpenAIAccountConnection tests an OpenAI account's connection
func
(
s
*
AccountTestService
)
testOpenAIAccountConnection
(
c
*
gin
.
Context
,
account
*
Account
,
modelID
string
)
error
{
ctx
:=
c
.
Request
.
Context
()
...
...
backend/internal/service/bedrock_request.go
0 → 100644
View file @
e6d59216
package
service
import
(
"encoding/json"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const
defaultBedrockRegion
=
"us-east-1"
var
bedrockCrossRegionPrefixes
=
[]
string
{
"us."
,
"eu."
,
"apac."
,
"jp."
,
"au."
,
"us-gov."
,
"global."
}
// BedrockCrossRegionPrefix 根据 AWS Region 返回 Bedrock 跨区域推理的模型 ID 前缀
// 参考: https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
func
BedrockCrossRegionPrefix
(
region
string
)
string
{
switch
{
case
strings
.
HasPrefix
(
region
,
"us-gov"
)
:
return
"us-gov"
// GovCloud 使用独立的 us-gov 前缀
case
strings
.
HasPrefix
(
region
,
"us-"
)
:
return
"us"
case
strings
.
HasPrefix
(
region
,
"eu-"
)
:
return
"eu"
case
region
==
"ap-northeast-1"
:
return
"jp"
// 日本区域使用独立的 jp 前缀(AWS 官方定义)
case
region
==
"ap-southeast-2"
:
return
"au"
// 澳大利亚区域使用独立的 au 前缀(AWS 官方定义)
case
strings
.
HasPrefix
(
region
,
"ap-"
)
:
return
"apac"
// 其余亚太区域使用通用 apac 前缀
case
strings
.
HasPrefix
(
region
,
"ca-"
)
:
return
"us"
// 加拿大区域使用 us 前缀的跨区域推理
case
strings
.
HasPrefix
(
region
,
"sa-"
)
:
return
"us"
// 南美区域使用 us 前缀的跨区域推理
default
:
return
"us"
}
}
// AdjustBedrockModelRegionPrefix 将模型 ID 的区域前缀替换为与当前 AWS Region 匹配的前缀
// 例如 region=eu-west-1 时,"us.anthropic.claude-opus-4-6-v1" → "eu.anthropic.claude-opus-4-6-v1"
// 特殊值 region="global" 强制使用 global. 前缀
func
AdjustBedrockModelRegionPrefix
(
modelID
,
region
string
)
string
{
var
targetPrefix
string
if
region
==
"global"
{
targetPrefix
=
"global"
}
else
{
targetPrefix
=
BedrockCrossRegionPrefix
(
region
)
}
for
_
,
p
:=
range
bedrockCrossRegionPrefixes
{
if
strings
.
HasPrefix
(
modelID
,
p
)
{
if
p
==
targetPrefix
+
"."
{
return
modelID
// 前缀已匹配,无需替换
}
return
targetPrefix
+
"."
+
modelID
[
len
(
p
)
:
]
}
}
// 模型 ID 没有已知区域前缀(如 "anthropic.claude-..."),不做修改
return
modelID
}
func
bedrockRuntimeRegion
(
account
*
Account
)
string
{
if
account
==
nil
{
return
defaultBedrockRegion
}
if
region
:=
account
.
GetCredential
(
"aws_region"
);
region
!=
""
{
return
region
}
return
defaultBedrockRegion
}
func
shouldForceBedrockGlobal
(
account
*
Account
)
bool
{
return
account
!=
nil
&&
account
.
GetCredential
(
"aws_force_global"
)
==
"true"
}
func
isRegionalBedrockModelID
(
modelID
string
)
bool
{
for
_
,
prefix
:=
range
bedrockCrossRegionPrefixes
{
if
strings
.
HasPrefix
(
modelID
,
prefix
)
{
return
true
}
}
return
false
}
func
isLikelyBedrockModelID
(
modelID
string
)
bool
{
lower
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
modelID
))
if
lower
==
""
{
return
false
}
if
strings
.
HasPrefix
(
lower
,
"arn:"
)
{
return
true
}
for
_
,
prefix
:=
range
[]
string
{
"anthropic."
,
"amazon."
,
"meta."
,
"mistral."
,
"cohere."
,
"ai21."
,
"deepseek."
,
"stability."
,
"writer."
,
"nova."
,
}
{
if
strings
.
HasPrefix
(
lower
,
prefix
)
{
return
true
}
}
return
isRegionalBedrockModelID
(
lower
)
}
func
normalizeBedrockModelID
(
modelID
string
)
(
normalized
string
,
shouldAdjustRegion
bool
,
ok
bool
)
{
modelID
=
strings
.
TrimSpace
(
modelID
)
if
modelID
==
""
{
return
""
,
false
,
false
}
if
mapped
,
exists
:=
domain
.
DefaultBedrockModelMapping
[
modelID
];
exists
{
return
mapped
,
true
,
true
}
if
isRegionalBedrockModelID
(
modelID
)
{
return
modelID
,
true
,
true
}
if
isLikelyBedrockModelID
(
modelID
)
{
return
modelID
,
false
,
true
}
return
""
,
false
,
false
}
// ResolveBedrockModelID resolves a requested Claude model into a Bedrock model ID.
// It applies account model_mapping first, then default Bedrock aliases, and finally
// adjusts Anthropic cross-region prefixes to match the account region.
func
ResolveBedrockModelID
(
account
*
Account
,
requestedModel
string
)
(
string
,
bool
)
{
if
account
==
nil
{
return
""
,
false
}
mappedModel
:=
account
.
GetMappedModel
(
requestedModel
)
modelID
,
shouldAdjustRegion
,
ok
:=
normalizeBedrockModelID
(
mappedModel
)
if
!
ok
{
return
""
,
false
}
if
shouldAdjustRegion
{
targetRegion
:=
bedrockRuntimeRegion
(
account
)
if
shouldForceBedrockGlobal
(
account
)
{
targetRegion
=
"global"
}
modelID
=
AdjustBedrockModelRegionPrefix
(
modelID
,
targetRegion
)
}
return
modelID
,
true
}
// BuildBedrockURL 构建 Bedrock InvokeModel 的 URL
// stream=true 时使用 invoke-with-response-stream 端点
// modelID 中的特殊字符会被 URL 编码(与 litellm 的 urllib.parse.quote(safe="") 对齐)
func
BuildBedrockURL
(
region
,
modelID
string
,
stream
bool
)
string
{
if
region
==
""
{
region
=
defaultBedrockRegion
}
encodedModelID
:=
url
.
PathEscape
(
modelID
)
// url.PathEscape 不编码冒号(RFC 允许 path 中出现 ":"),
// 但 AWS Bedrock 期望模型 ID 中的冒号被编码为 %3A
encodedModelID
=
strings
.
ReplaceAll
(
encodedModelID
,
":"
,
"%3A"
)
if
stream
{
return
fmt
.
Sprintf
(
"https://bedrock-runtime.%s.amazonaws.com/model/%s/invoke-with-response-stream"
,
region
,
encodedModelID
)
}
return
fmt
.
Sprintf
(
"https://bedrock-runtime.%s.amazonaws.com/model/%s/invoke"
,
region
,
encodedModelID
)
}
// PrepareBedrockRequestBody 处理请求体以适配 Bedrock API
// 1. 注入 anthropic_version
// 2. 注入 anthropic_beta(从客户端 anthropic-beta 头解析)
// 3. 移除 Bedrock 不支持的字段(model, stream, output_format, output_config)
// 4. 移除工具定义中的 custom 字段(Claude Code 会发送 custom: {defer_loading: true})
// 5. 清理 cache_control 中 Bedrock 不支持的字段(scope, ttl)
func
PrepareBedrockRequestBody
(
body
[]
byte
,
modelID
string
,
betaHeader
string
)
([]
byte
,
error
)
{
betaTokens
:=
ResolveBedrockBetaTokens
(
betaHeader
,
body
,
modelID
)
return
PrepareBedrockRequestBodyWithTokens
(
body
,
modelID
,
betaTokens
)
}
// PrepareBedrockRequestBodyWithTokens prepares a Bedrock request using pre-resolved beta tokens.
func
PrepareBedrockRequestBodyWithTokens
(
body
[]
byte
,
modelID
string
,
betaTokens
[]
string
)
([]
byte
,
error
)
{
var
err
error
// 注入 anthropic_version(Bedrock 要求)
body
,
err
=
sjson
.
SetBytes
(
body
,
"anthropic_version"
,
"bedrock-2023-05-31"
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"inject anthropic_version: %w"
,
err
)
}
// 注入 anthropic_beta(Bedrock Invoke 通过请求体传递 beta 头,而非 HTTP 头)
// 1. 从客户端 anthropic-beta header 解析
// 2. 根据请求体内容自动补齐必要的 beta token
// 参考 litellm: AnthropicModelInfo.get_anthropic_beta_list() + _get_tool_search_beta_header_for_bedrock()
if
len
(
betaTokens
)
>
0
{
body
,
err
=
sjson
.
SetBytes
(
body
,
"anthropic_beta"
,
betaTokens
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"inject anthropic_beta: %w"
,
err
)
}
}
// 移除 model 字段(Bedrock 通过 URL 指定模型)
body
,
err
=
sjson
.
DeleteBytes
(
body
,
"model"
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"remove model field: %w"
,
err
)
}
// 移除 stream 字段(Bedrock 通过不同端点控制流式,不接受请求体中的 stream 字段)
body
,
err
=
sjson
.
DeleteBytes
(
body
,
"stream"
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"remove stream field: %w"
,
err
)
}
// 转换 output_format(Bedrock Invoke 不支持此字段,但可将 schema 内联到最后一条 user message)
// 参考 litellm: _convert_output_format_to_inline_schema()
body
=
convertOutputFormatToInlineSchema
(
body
)
// 移除 output_config 字段(Bedrock Invoke 不支持)
body
,
err
=
sjson
.
DeleteBytes
(
body
,
"output_config"
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"remove output_config field: %w"
,
err
)
}
// 移除工具定义中的 custom 字段
// Claude Code (v2.1.69+) 在 tool 定义中发送 custom: {defer_loading: true},
// Anthropic API 接受但 Bedrock 会拒绝并报 "Extra inputs are not permitted"
body
=
removeCustomFieldFromTools
(
body
)
// 清理 cache_control 中 Bedrock 不支持的字段
body
=
sanitizeBedrockCacheControl
(
body
,
modelID
)
return
body
,
nil
}
// ResolveBedrockBetaTokens computes the final Bedrock beta token list before policy filtering.
func
ResolveBedrockBetaTokens
(
betaHeader
string
,
body
[]
byte
,
modelID
string
)
[]
string
{
betaTokens
:=
parseAnthropicBetaHeader
(
betaHeader
)
betaTokens
=
autoInjectBedrockBetaTokens
(
betaTokens
,
body
,
modelID
)
return
filterBedrockBetaTokens
(
betaTokens
)
}
// convertOutputFormatToInlineSchema 将 output_format 中的 JSON schema 内联到最后一条 user message
// Bedrock Invoke 不支持 output_format 参数,litellm 的做法是将 schema 追加到用户消息中
// 参考: litellm AmazonAnthropicClaudeMessagesConfig._convert_output_format_to_inline_schema()
func
convertOutputFormatToInlineSchema
(
body
[]
byte
)
[]
byte
{
outputFormat
:=
gjson
.
GetBytes
(
body
,
"output_format"
)
if
!
outputFormat
.
Exists
()
||
!
outputFormat
.
IsObject
()
{
return
body
}
// 先从请求体中移除 output_format
body
,
_
=
sjson
.
DeleteBytes
(
body
,
"output_format"
)
schema
:=
outputFormat
.
Get
(
"schema"
)
if
!
schema
.
Exists
()
{
return
body
}
// 找到最后一条 user message
messages
:=
gjson
.
GetBytes
(
body
,
"messages"
)
if
!
messages
.
Exists
()
||
!
messages
.
IsArray
()
{
return
body
}
msgArr
:=
messages
.
Array
()
lastUserIdx
:=
-
1
for
i
:=
len
(
msgArr
)
-
1
;
i
>=
0
;
i
--
{
if
msgArr
[
i
]
.
Get
(
"role"
)
.
String
()
==
"user"
{
lastUserIdx
=
i
break
}
}
if
lastUserIdx
<
0
{
return
body
}
// 将 schema 序列化为 JSON 文本追加到该 message 的 content 数组
schemaJSON
,
err
:=
json
.
Marshal
(
json
.
RawMessage
(
schema
.
Raw
))
if
err
!=
nil
{
return
body
}
content
:=
msgArr
[
lastUserIdx
]
.
Get
(
"content"
)
basePath
:=
fmt
.
Sprintf
(
"messages.%d.content"
,
lastUserIdx
)
if
content
.
IsArray
()
{
// 追加一个 text block 到 content 数组末尾
idx
:=
len
(
content
.
Array
())
body
,
_
=
sjson
.
SetBytes
(
body
,
fmt
.
Sprintf
(
"%s.%d.type"
,
basePath
,
idx
),
"text"
)
body
,
_
=
sjson
.
SetBytes
(
body
,
fmt
.
Sprintf
(
"%s.%d.text"
,
basePath
,
idx
),
string
(
schemaJSON
))
}
else
if
content
.
Type
==
gjson
.
String
{
// content 是纯字符串,转换为数组格式
originalText
:=
content
.
String
()
body
,
_
=
sjson
.
SetBytes
(
body
,
basePath
,
[]
map
[
string
]
string
{
{
"type"
:
"text"
,
"text"
:
originalText
},
{
"type"
:
"text"
,
"text"
:
string
(
schemaJSON
)},
})
}
return
body
}
// removeCustomFieldFromTools 移除 tools 数组中每个工具定义的 custom 字段
func
removeCustomFieldFromTools
(
body
[]
byte
)
[]
byte
{
tools
:=
gjson
.
GetBytes
(
body
,
"tools"
)
if
!
tools
.
Exists
()
||
!
tools
.
IsArray
()
{
return
body
}
var
err
error
for
i
:=
range
tools
.
Array
()
{
body
,
err
=
sjson
.
DeleteBytes
(
body
,
fmt
.
Sprintf
(
"tools.%d.custom"
,
i
))
if
err
!=
nil
{
// 删除失败不影响整体流程,跳过
continue
}
}
return
body
}
// claudeVersionRe 匹配 Claude 模型 ID 中的版本号部分
// 支持 claude-{tier}-{major}-{minor} 和 claude-{tier}-{major}.{minor} 格式
var
claudeVersionRe
=
regexp
.
MustCompile
(
`claude-(?:haiku|sonnet|opus)-(\d+)[-.](\d+)`
)
// isBedrockClaude45OrNewer 判断 Bedrock 模型 ID 是否为 Claude 4.5 或更新版本
// Claude 4.5+ 支持 cache_control 中的 ttl 字段("5m" 和 "1h")
func
isBedrockClaude45OrNewer
(
modelID
string
)
bool
{
lower
:=
strings
.
ToLower
(
modelID
)
matches
:=
claudeVersionRe
.
FindStringSubmatch
(
lower
)
if
matches
==
nil
{
return
false
}
major
,
_
:=
strconv
.
Atoi
(
matches
[
1
])
minor
,
_
:=
strconv
.
Atoi
(
matches
[
2
])
return
major
>
4
||
(
major
==
4
&&
minor
>=
5
)
}
// sanitizeBedrockCacheControl 清理 system 和 messages 中 cache_control 里
// Bedrock 不支持的字段:
// - scope:Bedrock 不支持(如 "global" 跨请求缓存)
// - ttl:仅 Claude 4.5+ 支持 "5m" 和 "1h",旧模型需要移除
func
sanitizeBedrockCacheControl
(
body
[]
byte
,
modelID
string
)
[]
byte
{
isClaude45
:=
isBedrockClaude45OrNewer
(
modelID
)
// 清理 system 数组中的 cache_control
systemArr
:=
gjson
.
GetBytes
(
body
,
"system"
)
if
systemArr
.
Exists
()
&&
systemArr
.
IsArray
()
{
for
i
,
item
:=
range
systemArr
.
Array
()
{
if
!
item
.
IsObject
()
{
continue
}
cc
:=
item
.
Get
(
"cache_control"
)
if
!
cc
.
Exists
()
||
!
cc
.
IsObject
()
{
continue
}
body
=
deleteCacheControlUnsupportedFields
(
body
,
fmt
.
Sprintf
(
"system.%d.cache_control"
,
i
),
cc
,
isClaude45
)
}
}
// 清理 messages 中的 cache_control
messages
:=
gjson
.
GetBytes
(
body
,
"messages"
)
if
!
messages
.
Exists
()
||
!
messages
.
IsArray
()
{
return
body
}
for
mi
,
msg
:=
range
messages
.
Array
()
{
if
!
msg
.
IsObject
()
{
continue
}
content
:=
msg
.
Get
(
"content"
)
if
!
content
.
Exists
()
||
!
content
.
IsArray
()
{
continue
}
for
ci
,
block
:=
range
content
.
Array
()
{
if
!
block
.
IsObject
()
{
continue
}
cc
:=
block
.
Get
(
"cache_control"
)
if
!
cc
.
Exists
()
||
!
cc
.
IsObject
()
{
continue
}
body
=
deleteCacheControlUnsupportedFields
(
body
,
fmt
.
Sprintf
(
"messages.%d.content.%d.cache_control"
,
mi
,
ci
),
cc
,
isClaude45
)
}
}
return
body
}
// deleteCacheControlUnsupportedFields 删除给定 cache_control 路径下 Bedrock 不支持的字段
func
deleteCacheControlUnsupportedFields
(
body
[]
byte
,
basePath
string
,
cc
gjson
.
Result
,
isClaude45
bool
)
[]
byte
{
// Bedrock 不支持 scope(如 "global")
if
cc
.
Get
(
"scope"
)
.
Exists
()
{
body
,
_
=
sjson
.
DeleteBytes
(
body
,
basePath
+
".scope"
)
}
// ttl:仅 Claude 4.5+ 支持 "5m" 和 "1h",其余情况移除
ttl
:=
cc
.
Get
(
"ttl"
)
if
ttl
.
Exists
()
{
shouldRemove
:=
true
if
isClaude45
{
v
:=
ttl
.
String
()
if
v
==
"5m"
||
v
==
"1h"
{
shouldRemove
=
false
}
}
if
shouldRemove
{
body
,
_
=
sjson
.
DeleteBytes
(
body
,
basePath
+
".ttl"
)
}
}
return
body
}
// parseAnthropicBetaHeader 解析 anthropic-beta 头的逗号分隔字符串为 token 列表
func
parseAnthropicBetaHeader
(
header
string
)
[]
string
{
header
=
strings
.
TrimSpace
(
header
)
if
header
==
""
{
return
nil
}
if
strings
.
HasPrefix
(
header
,
"["
)
&&
strings
.
HasSuffix
(
header
,
"]"
)
{
var
parsed
[]
any
if
err
:=
json
.
Unmarshal
([]
byte
(
header
),
&
parsed
);
err
==
nil
{
tokens
:=
make
([]
string
,
0
,
len
(
parsed
))
for
_
,
item
:=
range
parsed
{
token
:=
strings
.
TrimSpace
(
fmt
.
Sprint
(
item
))
if
token
!=
""
{
tokens
=
append
(
tokens
,
token
)
}
}
return
tokens
}
}
var
tokens
[]
string
for
_
,
part
:=
range
strings
.
Split
(
header
,
","
)
{
t
:=
strings
.
TrimSpace
(
part
)
if
t
!=
""
{
tokens
=
append
(
tokens
,
t
)
}
}
return
tokens
}
// bedrockSupportedBetaTokens 是 Bedrock Invoke 支持的 beta 头白名单
// 参考: litellm/litellm/llms/bedrock/common_utils.py (anthropic_beta_headers_config.json)
// 更新策略: 当 AWS Bedrock 新增支持的 beta token 时需同步更新此白名单
var
bedrockSupportedBetaTokens
=
map
[
string
]
bool
{
"computer-use-2025-01-24"
:
true
,
"computer-use-2025-11-24"
:
true
,
"context-1m-2025-08-07"
:
true
,
"context-management-2025-06-27"
:
true
,
"compact-2026-01-12"
:
true
,
"interleaved-thinking-2025-05-14"
:
true
,
"tool-search-tool-2025-10-19"
:
true
,
"tool-examples-2025-10-29"
:
true
,
}
// bedrockBetaTokenTransforms 定义 Bedrock Invoke 特有的 beta 头转换规则
// Anthropic 直接 API 使用通用头,Bedrock Invoke 需要特定的替代头
var
bedrockBetaTokenTransforms
=
map
[
string
]
string
{
"advanced-tool-use-2025-11-20"
:
"tool-search-tool-2025-10-19"
,
}
// autoInjectBedrockBetaTokens 根据请求体内容自动补齐必要的 beta token
// 参考 litellm: AnthropicModelInfo.get_anthropic_beta_list() 和
// AmazonAnthropicClaudeMessagesConfig._get_tool_search_beta_header_for_bedrock()
//
// 客户端(特别是非 Claude Code 客户端)可能只在 body 中启用了功能而不在 header 中带对应 beta token,
// 这里通过检测请求体特征自动补齐,确保 Bedrock Invoke 不会因缺少必要 beta 头而 400。
func
autoInjectBedrockBetaTokens
(
tokens
[]
string
,
body
[]
byte
,
modelID
string
)
[]
string
{
seen
:=
make
(
map
[
string
]
bool
,
len
(
tokens
))
for
_
,
t
:=
range
tokens
{
seen
[
t
]
=
true
}
inject
:=
func
(
token
string
)
{
if
!
seen
[
token
]
{
tokens
=
append
(
tokens
,
token
)
seen
[
token
]
=
true
}
}
// 检测 thinking / interleaved thinking
// 请求体中有 "thinking" 字段 → 需要 interleaved-thinking beta
if
gjson
.
GetBytes
(
body
,
"thinking"
)
.
Exists
()
{
inject
(
"interleaved-thinking-2025-05-14"
)
}
// 检测 computer_use 工具
// tools 中有 type="computer_20xxxxxx" 的工具 → 需要 computer-use beta
tools
:=
gjson
.
GetBytes
(
body
,
"tools"
)
if
tools
.
Exists
()
&&
tools
.
IsArray
()
{
toolSearchUsed
:=
false
programmaticToolCallingUsed
:=
false
inputExamplesUsed
:=
false
for
_
,
tool
:=
range
tools
.
Array
()
{
toolType
:=
tool
.
Get
(
"type"
)
.
String
()
if
strings
.
HasPrefix
(
toolType
,
"computer_20"
)
{
inject
(
"computer-use-2025-11-24"
)
}
if
isBedrockToolSearchType
(
toolType
)
{
toolSearchUsed
=
true
}
if
hasCodeExecutionAllowedCallers
(
tool
)
{
programmaticToolCallingUsed
=
true
}
if
hasInputExamples
(
tool
)
{
inputExamplesUsed
=
true
}
}
if
programmaticToolCallingUsed
||
inputExamplesUsed
{
// programmatic tool calling 和 input examples 需要 advanced-tool-use,
// 后续 filterBedrockBetaTokens 会将其转换为 Bedrock 特定的 tool-search-tool
inject
(
"advanced-tool-use-2025-11-20"
)
}
if
toolSearchUsed
&&
bedrockModelSupportsToolSearch
(
modelID
)
{
// 纯 tool search(无 programmatic/inputExamples)时直接注入 Bedrock 特定头,
// 跳过 advanced-tool-use → tool-search-tool 的转换步骤(与 litellm 对齐)
if
!
programmaticToolCallingUsed
&&
!
inputExamplesUsed
{
inject
(
"tool-search-tool-2025-10-19"
)
}
else
{
inject
(
"advanced-tool-use-2025-11-20"
)
}
}
}
return
tokens
}
func
isBedrockToolSearchType
(
toolType
string
)
bool
{
return
toolType
==
"tool_search_tool_regex_20251119"
||
toolType
==
"tool_search_tool_bm25_20251119"
}
func
hasCodeExecutionAllowedCallers
(
tool
gjson
.
Result
)
bool
{
allowedCallers
:=
tool
.
Get
(
"allowed_callers"
)
if
containsStringInJSONArray
(
allowedCallers
,
"code_execution_20250825"
)
{
return
true
}
return
containsStringInJSONArray
(
tool
.
Get
(
"function.allowed_callers"
),
"code_execution_20250825"
)
}
func
hasInputExamples
(
tool
gjson
.
Result
)
bool
{
if
arr
:=
tool
.
Get
(
"input_examples"
);
arr
.
Exists
()
&&
arr
.
IsArray
()
&&
len
(
arr
.
Array
())
>
0
{
return
true
}
arr
:=
tool
.
Get
(
"function.input_examples"
)
return
arr
.
Exists
()
&&
arr
.
IsArray
()
&&
len
(
arr
.
Array
())
>
0
}
func
containsStringInJSONArray
(
result
gjson
.
Result
,
target
string
)
bool
{
if
!
result
.
Exists
()
||
!
result
.
IsArray
()
{
return
false
}
for
_
,
item
:=
range
result
.
Array
()
{
if
item
.
String
()
==
target
{
return
true
}
}
return
false
}
// bedrockModelSupportsToolSearch 判断 Bedrock 模型是否支持 tool search
// 目前仅 Claude Opus/Sonnet 4.5+ 支持,Haiku 不支持
func
bedrockModelSupportsToolSearch
(
modelID
string
)
bool
{
lower
:=
strings
.
ToLower
(
modelID
)
matches
:=
claudeVersionRe
.
FindStringSubmatch
(
lower
)
if
matches
==
nil
{
return
false
}
// Haiku 不支持 tool search
if
strings
.
Contains
(
lower
,
"haiku"
)
{
return
false
}
major
,
_
:=
strconv
.
Atoi
(
matches
[
1
])
minor
,
_
:=
strconv
.
Atoi
(
matches
[
2
])
return
major
>
4
||
(
major
==
4
&&
minor
>=
5
)
}
// filterBedrockBetaTokens 过滤并转换 beta token 列表,仅保留 Bedrock Invoke 支持的 token
// 1. 应用转换规则(如 advanced-tool-use → tool-search-tool)
// 2. 过滤掉 Bedrock 不支持的 token(如 output-128k, files-api, structured-outputs 等)
// 3. 自动关联 tool-examples(当 tool-search-tool 存在时)
func
filterBedrockBetaTokens
(
tokens
[]
string
)
[]
string
{
seen
:=
make
(
map
[
string
]
bool
,
len
(
tokens
))
var
result
[]
string
for
_
,
t
:=
range
tokens
{
// 应用转换规则
if
replacement
,
ok
:=
bedrockBetaTokenTransforms
[
t
];
ok
{
t
=
replacement
}
// 只保留白名单中的 token,且去重
if
bedrockSupportedBetaTokens
[
t
]
&&
!
seen
[
t
]
{
result
=
append
(
result
,
t
)
seen
[
t
]
=
true
}
}
// 自动关联: tool-search-tool 存在时,确保 tool-examples 也存在
if
seen
[
"tool-search-tool-2025-10-19"
]
&&
!
seen
[
"tool-examples-2025-10-29"
]
{
result
=
append
(
result
,
"tool-examples-2025-10-29"
)
}
return
result
}
backend/internal/service/bedrock_request_test.go
0 → 100644
View file @
e6d59216
package
service
import
(
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func
TestPrepareBedrockRequestBody_BasicFields
(
t
*
testing
.
T
)
{
input
:=
`{"model":"claude-opus-4-6","stream":true,"max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-opus-4-6-v1"
,
""
)
require
.
NoError
(
t
,
err
)
// anthropic_version 应被注入
assert
.
Equal
(
t
,
"bedrock-2023-05-31"
,
gjson
.
GetBytes
(
result
,
"anthropic_version"
)
.
String
())
// model 和 stream 应被移除
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"model"
)
.
Exists
())
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"stream"
)
.
Exists
())
// max_tokens 应保留
assert
.
Equal
(
t
,
int64
(
1024
),
gjson
.
GetBytes
(
result
,
"max_tokens"
)
.
Int
())
}
func
TestPrepareBedrockRequestBody_OutputFormatInlineSchema
(
t
*
testing
.
T
)
{
t
.
Run
(
"schema inlined into last user message array content"
,
func
(
t
*
testing
.
T
)
{
input
:=
`{"model":"claude-sonnet-4-5","output_format":{"type":"json","schema":{"name":"string"}},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-sonnet-4-5-v1"
,
""
)
require
.
NoError
(
t
,
err
)
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"output_format"
)
.
Exists
())
// schema 应内联到最后一条 user message 的 content 数组末尾
contentArr
:=
gjson
.
GetBytes
(
result
,
"messages.0.content"
)
.
Array
()
require
.
Len
(
t
,
contentArr
,
2
)
assert
.
Equal
(
t
,
"text"
,
contentArr
[
1
]
.
Get
(
"type"
)
.
String
())
assert
.
Contains
(
t
,
contentArr
[
1
]
.
Get
(
"text"
)
.
String
(),
`"name":"string"`
)
})
t
.
Run
(
"schema inlined into string content"
,
func
(
t
*
testing
.
T
)
{
input
:=
`{"model":"claude-sonnet-4-5","output_format":{"type":"json","schema":{"result":"number"}},"messages":[{"role":"user","content":"compute this"}]}`
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-sonnet-4-5-v1"
,
""
)
require
.
NoError
(
t
,
err
)
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"output_format"
)
.
Exists
())
contentArr
:=
gjson
.
GetBytes
(
result
,
"messages.0.content"
)
.
Array
()
require
.
Len
(
t
,
contentArr
,
2
)
assert
.
Equal
(
t
,
"compute this"
,
contentArr
[
0
]
.
Get
(
"text"
)
.
String
())
assert
.
Contains
(
t
,
contentArr
[
1
]
.
Get
(
"text"
)
.
String
(),
`"result":"number"`
)
})
t
.
Run
(
"no schema field just removes output_format"
,
func
(
t
*
testing
.
T
)
{
input
:=
`{"model":"claude-sonnet-4-5","output_format":{"type":"json"},"messages":[{"role":"user","content":"hi"}]}`
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-sonnet-4-5-v1"
,
""
)
require
.
NoError
(
t
,
err
)
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"output_format"
)
.
Exists
())
})
t
.
Run
(
"no messages just removes output_format"
,
func
(
t
*
testing
.
T
)
{
input
:=
`{"model":"claude-sonnet-4-5","output_format":{"type":"json","schema":{"name":"string"}}}`
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-sonnet-4-5-v1"
,
""
)
require
.
NoError
(
t
,
err
)
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"output_format"
)
.
Exists
())
})
}
func
TestPrepareBedrockRequestBody_RemoveOutputConfig
(
t
*
testing
.
T
)
{
input
:=
`{"model":"claude-sonnet-4-5","output_config":{"max_tokens":100},"messages":[]}`
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-sonnet-4-5-v1"
,
""
)
require
.
NoError
(
t
,
err
)
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"output_config"
)
.
Exists
())
}
func
TestRemoveCustomFieldFromTools
(
t
*
testing
.
T
)
{
input
:=
`{
"tools": [
{"name":"tool1","custom":{"defer_loading":true},"description":"desc1"},
{"name":"tool2","description":"desc2"},
{"name":"tool3","custom":{"defer_loading":true,"other":123},"description":"desc3"}
]
}`
result
:=
removeCustomFieldFromTools
([]
byte
(
input
))
tools
:=
gjson
.
GetBytes
(
result
,
"tools"
)
.
Array
()
require
.
Len
(
t
,
tools
,
3
)
// custom 应被移除
assert
.
False
(
t
,
tools
[
0
]
.
Get
(
"custom"
)
.
Exists
())
// name/description 应保留
assert
.
Equal
(
t
,
"tool1"
,
tools
[
0
]
.
Get
(
"name"
)
.
String
())
assert
.
Equal
(
t
,
"desc1"
,
tools
[
0
]
.
Get
(
"description"
)
.
String
())
// 没有 custom 的工具不受影响
assert
.
Equal
(
t
,
"tool2"
,
tools
[
1
]
.
Get
(
"name"
)
.
String
())
// 第三个工具的 custom 也应被移除
assert
.
False
(
t
,
tools
[
2
]
.
Get
(
"custom"
)
.
Exists
())
assert
.
Equal
(
t
,
"tool3"
,
tools
[
2
]
.
Get
(
"name"
)
.
String
())
}
func
TestRemoveCustomFieldFromTools_NoTools
(
t
*
testing
.
T
)
{
input
:=
`{"messages":[{"role":"user","content":"hi"}]}`
result
:=
removeCustomFieldFromTools
([]
byte
(
input
))
// 无 tools 时不改变原始数据
assert
.
JSONEq
(
t
,
input
,
string
(
result
))
}
func
TestSanitizeBedrockCacheControl_RemoveScope
(
t
*
testing
.
T
)
{
input
:=
`{
"system": [{"type":"text","text":"sys","cache_control":{"type":"ephemeral","scope":"global"}}],
"messages": [{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral","scope":"global"}}]}]
}`
result
:=
sanitizeBedrockCacheControl
([]
byte
(
input
),
"us.anthropic.claude-opus-4-6-v1"
)
// scope 应被移除
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"system.0.cache_control.scope"
)
.
Exists
())
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"messages.0.content.0.cache_control.scope"
)
.
Exists
())
// type 应保留
assert
.
Equal
(
t
,
"ephemeral"
,
gjson
.
GetBytes
(
result
,
"system.0.cache_control.type"
)
.
String
())
assert
.
Equal
(
t
,
"ephemeral"
,
gjson
.
GetBytes
(
result
,
"messages.0.content.0.cache_control.type"
)
.
String
())
}
func
TestSanitizeBedrockCacheControl_TTL_OldModel
(
t
*
testing
.
T
)
{
input
:=
`{
"system": [{"type":"text","text":"sys","cache_control":{"type":"ephemeral","ttl":"5m"}}]
}`
// 旧模型(Claude 3.5)不支持 ttl
result
:=
sanitizeBedrockCacheControl
([]
byte
(
input
),
"anthropic.claude-3-5-sonnet-20241022-v2:0"
)
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"system.0.cache_control.ttl"
)
.
Exists
())
assert
.
Equal
(
t
,
"ephemeral"
,
gjson
.
GetBytes
(
result
,
"system.0.cache_control.type"
)
.
String
())
}
func
TestSanitizeBedrockCacheControl_TTL_Claude45_Supported
(
t
*
testing
.
T
)
{
input
:=
`{
"system": [{"type":"text","text":"sys","cache_control":{"type":"ephemeral","ttl":"5m"}}]
}`
// Claude 4.5+ 支持 "5m" 和 "1h"
result
:=
sanitizeBedrockCacheControl
([]
byte
(
input
),
"us.anthropic.claude-sonnet-4-5-20250929-v1:0"
)
assert
.
True
(
t
,
gjson
.
GetBytes
(
result
,
"system.0.cache_control.ttl"
)
.
Exists
())
assert
.
Equal
(
t
,
"5m"
,
gjson
.
GetBytes
(
result
,
"system.0.cache_control.ttl"
)
.
String
())
}
func
TestSanitizeBedrockCacheControl_TTL_Claude45_UnsupportedValue
(
t
*
testing
.
T
)
{
input
:=
`{
"system": [{"type":"text","text":"sys","cache_control":{"type":"ephemeral","ttl":"10m"}}]
}`
// Claude 4.5 不支持 "10m"
result
:=
sanitizeBedrockCacheControl
([]
byte
(
input
),
"us.anthropic.claude-sonnet-4-5-20250929-v1:0"
)
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"system.0.cache_control.ttl"
)
.
Exists
())
}
func
TestSanitizeBedrockCacheControl_TTL_Claude46
(
t
*
testing
.
T
)
{
input
:=
`{
"messages": [{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral","ttl":"1h"}}]}]
}`
result
:=
sanitizeBedrockCacheControl
([]
byte
(
input
),
"us.anthropic.claude-opus-4-6-v1"
)
assert
.
True
(
t
,
gjson
.
GetBytes
(
result
,
"messages.0.content.0.cache_control.ttl"
)
.
Exists
())
assert
.
Equal
(
t
,
"1h"
,
gjson
.
GetBytes
(
result
,
"messages.0.content.0.cache_control.ttl"
)
.
String
())
}
func
TestSanitizeBedrockCacheControl_NoCacheControl
(
t
*
testing
.
T
)
{
input
:=
`{"system":[{"type":"text","text":"sys"}],"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`
result
:=
sanitizeBedrockCacheControl
([]
byte
(
input
),
"us.anthropic.claude-opus-4-6-v1"
)
// 无 cache_control 时不改变原始数据
assert
.
JSONEq
(
t
,
input
,
string
(
result
))
}
func
TestIsBedrockClaude45OrNewer
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
modelID
string
expect
bool
}{
{
"us.anthropic.claude-opus-4-6-v1"
,
true
},
{
"us.anthropic.claude-sonnet-4-6"
,
true
},
{
"us.anthropic.claude-sonnet-4-5-20250929-v1:0"
,
true
},
{
"us.anthropic.claude-opus-4-5-20251101-v1:0"
,
true
},
{
"us.anthropic.claude-haiku-4-5-20251001-v1:0"
,
true
},
{
"anthropic.claude-3-5-sonnet-20241022-v2:0"
,
false
},
{
"anthropic.claude-3-opus-20240229-v1:0"
,
false
},
{
"anthropic.claude-3-haiku-20240307-v1:0"
,
false
},
// 未来版本应自动支持
{
"us.anthropic.claude-sonnet-5-0-v1"
,
true
},
{
"us.anthropic.claude-opus-4-7-v1"
,
true
},
// 旧版本
{
"anthropic.claude-opus-4-1-v1"
,
false
},
{
"anthropic.claude-sonnet-4-0-v1"
,
false
},
// 非 Claude 模型
{
"amazon.nova-pro-v1"
,
false
},
{
"meta.llama3-70b"
,
false
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
modelID
,
func
(
t
*
testing
.
T
)
{
assert
.
Equal
(
t
,
tt
.
expect
,
isBedrockClaude45OrNewer
(
tt
.
modelID
))
})
}
}
func
TestPrepareBedrockRequestBody_FullIntegration
(
t
*
testing
.
T
)
{
// 模拟一个完整的 Claude Code 请求
input
:=
`{
"model": "claude-opus-4-6",
"stream": true,
"max_tokens": 16384,
"output_format": {"type": "json", "schema": {"result": "string"}},
"output_config": {"max_tokens": 100},
"system": [{"type": "text", "text": "You are helpful", "cache_control": {"type": "ephemeral", "scope": "global", "ttl": "5m"}}],
"messages": [
{"role": "user", "content": [{"type": "text", "text": "hello", "cache_control": {"type": "ephemeral", "ttl": "1h"}}]}
],
"tools": [
{"name": "bash", "description": "Run bash", "custom": {"defer_loading": true}, "input_schema": {"type": "object"}},
{"name": "read", "description": "Read file", "input_schema": {"type": "object"}}
]
}`
betaHeader
:=
"interleaved-thinking-2025-05-14, context-1m-2025-08-07, compact-2026-01-12"
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-opus-4-6-v1"
,
betaHeader
)
require
.
NoError
(
t
,
err
)
// 基本字段
assert
.
Equal
(
t
,
"bedrock-2023-05-31"
,
gjson
.
GetBytes
(
result
,
"anthropic_version"
)
.
String
())
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"model"
)
.
Exists
())
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"stream"
)
.
Exists
())
assert
.
Equal
(
t
,
int64
(
16384
),
gjson
.
GetBytes
(
result
,
"max_tokens"
)
.
Int
())
// anthropic_beta 应包含所有 beta tokens
betaArr
:=
gjson
.
GetBytes
(
result
,
"anthropic_beta"
)
.
Array
()
require
.
Len
(
t
,
betaArr
,
3
)
assert
.
Equal
(
t
,
"interleaved-thinking-2025-05-14"
,
betaArr
[
0
]
.
String
())
assert
.
Equal
(
t
,
"context-1m-2025-08-07"
,
betaArr
[
1
]
.
String
())
assert
.
Equal
(
t
,
"compact-2026-01-12"
,
betaArr
[
2
]
.
String
())
// output_format 应被移除,schema 内联到最后一条 user message
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"output_format"
)
.
Exists
())
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"output_config"
)
.
Exists
())
// content 数组:原始 text block + 内联 schema block
contentArr
:=
gjson
.
GetBytes
(
result
,
"messages.0.content"
)
.
Array
()
require
.
Len
(
t
,
contentArr
,
2
)
assert
.
Equal
(
t
,
"hello"
,
contentArr
[
0
]
.
Get
(
"text"
)
.
String
())
assert
.
Contains
(
t
,
contentArr
[
1
]
.
Get
(
"text"
)
.
String
(),
`"result":"string"`
)
// tools 中的 custom 应被移除
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"tools.0.custom"
)
.
Exists
())
assert
.
Equal
(
t
,
"bash"
,
gjson
.
GetBytes
(
result
,
"tools.0.name"
)
.
String
())
assert
.
Equal
(
t
,
"read"
,
gjson
.
GetBytes
(
result
,
"tools.1.name"
)
.
String
())
// cache_control: scope 应被移除,ttl 在 Claude 4.6 上保留合法值
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"system.0.cache_control.scope"
)
.
Exists
())
assert
.
Equal
(
t
,
"ephemeral"
,
gjson
.
GetBytes
(
result
,
"system.0.cache_control.type"
)
.
String
())
assert
.
Equal
(
t
,
"5m"
,
gjson
.
GetBytes
(
result
,
"system.0.cache_control.ttl"
)
.
String
())
assert
.
Equal
(
t
,
"1h"
,
gjson
.
GetBytes
(
result
,
"messages.0.content.0.cache_control.ttl"
)
.
String
())
}
func
TestPrepareBedrockRequestBody_BetaHeader
(
t
*
testing
.
T
)
{
input
:=
`{"messages":[{"role":"user","content":"hi"}],"max_tokens":100}`
t
.
Run
(
"empty beta header"
,
func
(
t
*
testing
.
T
)
{
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-opus-4-6-v1"
,
""
)
require
.
NoError
(
t
,
err
)
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"anthropic_beta"
)
.
Exists
())
})
t
.
Run
(
"single beta token"
,
func
(
t
*
testing
.
T
)
{
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-opus-4-6-v1"
,
"interleaved-thinking-2025-05-14"
)
require
.
NoError
(
t
,
err
)
arr
:=
gjson
.
GetBytes
(
result
,
"anthropic_beta"
)
.
Array
()
require
.
Len
(
t
,
arr
,
1
)
assert
.
Equal
(
t
,
"interleaved-thinking-2025-05-14"
,
arr
[
0
]
.
String
())
})
t
.
Run
(
"multiple beta tokens with spaces"
,
func
(
t
*
testing
.
T
)
{
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-opus-4-6-v1"
,
"interleaved-thinking-2025-05-14 , context-1m-2025-08-07 "
)
require
.
NoError
(
t
,
err
)
arr
:=
gjson
.
GetBytes
(
result
,
"anthropic_beta"
)
.
Array
()
require
.
Len
(
t
,
arr
,
2
)
assert
.
Equal
(
t
,
"interleaved-thinking-2025-05-14"
,
arr
[
0
]
.
String
())
assert
.
Equal
(
t
,
"context-1m-2025-08-07"
,
arr
[
1
]
.
String
())
})
t
.
Run
(
"json array beta header"
,
func
(
t
*
testing
.
T
)
{
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-opus-4-6-v1"
,
`["interleaved-thinking-2025-05-14","context-1m-2025-08-07"]`
)
require
.
NoError
(
t
,
err
)
arr
:=
gjson
.
GetBytes
(
result
,
"anthropic_beta"
)
.
Array
()
require
.
Len
(
t
,
arr
,
2
)
assert
.
Equal
(
t
,
"interleaved-thinking-2025-05-14"
,
arr
[
0
]
.
String
())
assert
.
Equal
(
t
,
"context-1m-2025-08-07"
,
arr
[
1
]
.
String
())
})
}
func
TestParseAnthropicBetaHeader
(
t
*
testing
.
T
)
{
assert
.
Nil
(
t
,
parseAnthropicBetaHeader
(
""
))
assert
.
Equal
(
t
,
[]
string
{
"a"
},
parseAnthropicBetaHeader
(
"a"
))
assert
.
Equal
(
t
,
[]
string
{
"a"
,
"b"
},
parseAnthropicBetaHeader
(
"a,b"
))
assert
.
Equal
(
t
,
[]
string
{
"a"
,
"b"
},
parseAnthropicBetaHeader
(
"a , b "
))
assert
.
Equal
(
t
,
[]
string
{
"a"
,
"b"
,
"c"
},
parseAnthropicBetaHeader
(
"a,b,c"
))
assert
.
Equal
(
t
,
[]
string
{
"a"
,
"b"
},
parseAnthropicBetaHeader
(
`["a","b"]`
))
}
func
TestFilterBedrockBetaTokens
(
t
*
testing
.
T
)
{
t
.
Run
(
"supported tokens pass through"
,
func
(
t
*
testing
.
T
)
{
tokens
:=
[]
string
{
"interleaved-thinking-2025-05-14"
,
"context-1m-2025-08-07"
,
"compact-2026-01-12"
}
result
:=
filterBedrockBetaTokens
(
tokens
)
assert
.
Equal
(
t
,
tokens
,
result
)
})
t
.
Run
(
"unsupported tokens are filtered out"
,
func
(
t
*
testing
.
T
)
{
tokens
:=
[]
string
{
"interleaved-thinking-2025-05-14"
,
"output-128k-2025-02-19"
,
"files-api-2025-04-14"
,
"structured-outputs-2025-11-13"
}
result
:=
filterBedrockBetaTokens
(
tokens
)
assert
.
Equal
(
t
,
[]
string
{
"interleaved-thinking-2025-05-14"
},
result
)
})
t
.
Run
(
"advanced-tool-use transforms to tool-search-tool"
,
func
(
t
*
testing
.
T
)
{
tokens
:=
[]
string
{
"advanced-tool-use-2025-11-20"
}
result
:=
filterBedrockBetaTokens
(
tokens
)
assert
.
Contains
(
t
,
result
,
"tool-search-tool-2025-10-19"
)
// tool-examples 自动关联
assert
.
Contains
(
t
,
result
,
"tool-examples-2025-10-29"
)
})
t
.
Run
(
"tool-search-tool auto-associates tool-examples"
,
func
(
t
*
testing
.
T
)
{
tokens
:=
[]
string
{
"tool-search-tool-2025-10-19"
}
result
:=
filterBedrockBetaTokens
(
tokens
)
assert
.
Contains
(
t
,
result
,
"tool-search-tool-2025-10-19"
)
assert
.
Contains
(
t
,
result
,
"tool-examples-2025-10-29"
)
})
t
.
Run
(
"no duplication when tool-examples already present"
,
func
(
t
*
testing
.
T
)
{
tokens
:=
[]
string
{
"tool-search-tool-2025-10-19"
,
"tool-examples-2025-10-29"
}
result
:=
filterBedrockBetaTokens
(
tokens
)
count
:=
0
for
_
,
t
:=
range
result
{
if
t
==
"tool-examples-2025-10-29"
{
count
++
}
}
assert
.
Equal
(
t
,
1
,
count
)
})
t
.
Run
(
"empty input returns nil"
,
func
(
t
*
testing
.
T
)
{
result
:=
filterBedrockBetaTokens
(
nil
)
assert
.
Nil
(
t
,
result
)
})
t
.
Run
(
"all unsupported returns nil"
,
func
(
t
*
testing
.
T
)
{
result
:=
filterBedrockBetaTokens
([]
string
{
"output-128k-2025-02-19"
,
"effort-2025-11-24"
})
assert
.
Nil
(
t
,
result
)
})
t
.
Run
(
"duplicate tokens are deduplicated"
,
func
(
t
*
testing
.
T
)
{
tokens
:=
[]
string
{
"context-1m-2025-08-07"
,
"context-1m-2025-08-07"
}
result
:=
filterBedrockBetaTokens
(
tokens
)
assert
.
Equal
(
t
,
[]
string
{
"context-1m-2025-08-07"
},
result
)
})
}
func
TestPrepareBedrockRequestBody_BetaFiltering
(
t
*
testing
.
T
)
{
input
:=
`{"messages":[{"role":"user","content":"hi"}],"max_tokens":100}`
t
.
Run
(
"unsupported beta tokens are filtered"
,
func
(
t
*
testing
.
T
)
{
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-opus-4-6-v1"
,
"interleaved-thinking-2025-05-14, output-128k-2025-02-19, files-api-2025-04-14"
)
require
.
NoError
(
t
,
err
)
arr
:=
gjson
.
GetBytes
(
result
,
"anthropic_beta"
)
.
Array
()
require
.
Len
(
t
,
arr
,
1
)
assert
.
Equal
(
t
,
"interleaved-thinking-2025-05-14"
,
arr
[
0
]
.
String
())
})
t
.
Run
(
"advanced-tool-use transformed in full pipeline"
,
func
(
t
*
testing
.
T
)
{
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-opus-4-6-v1"
,
"advanced-tool-use-2025-11-20"
)
require
.
NoError
(
t
,
err
)
arr
:=
gjson
.
GetBytes
(
result
,
"anthropic_beta"
)
.
Array
()
require
.
Len
(
t
,
arr
,
2
)
assert
.
Equal
(
t
,
"tool-search-tool-2025-10-19"
,
arr
[
0
]
.
String
())
assert
.
Equal
(
t
,
"tool-examples-2025-10-29"
,
arr
[
1
]
.
String
())
})
}
func
TestBedrockCrossRegionPrefix
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
region
string
expect
string
}{
// US regions
{
"us-east-1"
,
"us"
},
{
"us-east-2"
,
"us"
},
{
"us-west-1"
,
"us"
},
{
"us-west-2"
,
"us"
},
// GovCloud
{
"us-gov-east-1"
,
"us-gov"
},
{
"us-gov-west-1"
,
"us-gov"
},
// EU regions
{
"eu-west-1"
,
"eu"
},
{
"eu-west-2"
,
"eu"
},
{
"eu-west-3"
,
"eu"
},
{
"eu-central-1"
,
"eu"
},
{
"eu-central-2"
,
"eu"
},
{
"eu-north-1"
,
"eu"
},
{
"eu-south-1"
,
"eu"
},
// APAC regions
{
"ap-northeast-1"
,
"jp"
},
{
"ap-northeast-2"
,
"apac"
},
{
"ap-southeast-1"
,
"apac"
},
{
"ap-southeast-2"
,
"au"
},
{
"ap-south-1"
,
"apac"
},
// Canada / South America fallback to us
{
"ca-central-1"
,
"us"
},
{
"sa-east-1"
,
"us"
},
// Unknown defaults to us
{
"me-south-1"
,
"us"
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
region
,
func
(
t
*
testing
.
T
)
{
assert
.
Equal
(
t
,
tt
.
expect
,
BedrockCrossRegionPrefix
(
tt
.
region
))
})
}
}
func
TestResolveBedrockModelID
(
t
*
testing
.
T
)
{
t
.
Run
(
"default alias resolves and adjusts region"
,
func
(
t
*
testing
.
T
)
{
account
:=
&
Account
{
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeBedrock
,
Credentials
:
map
[
string
]
any
{
"aws_region"
:
"eu-west-1"
,
},
}
modelID
,
ok
:=
ResolveBedrockModelID
(
account
,
"claude-sonnet-4-5"
)
require
.
True
(
t
,
ok
)
assert
.
Equal
(
t
,
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0"
,
modelID
)
})
t
.
Run
(
"custom alias mapping reuses default bedrock mapping"
,
func
(
t
*
testing
.
T
)
{
account
:=
&
Account
{
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeBedrock
,
Credentials
:
map
[
string
]
any
{
"aws_region"
:
"ap-southeast-2"
,
"model_mapping"
:
map
[
string
]
any
{
"claude-*"
:
"claude-opus-4-6"
,
},
},
}
modelID
,
ok
:=
ResolveBedrockModelID
(
account
,
"claude-opus-4-6-thinking"
)
require
.
True
(
t
,
ok
)
assert
.
Equal
(
t
,
"au.anthropic.claude-opus-4-6-v1"
,
modelID
)
})
t
.
Run
(
"force global rewrites anthropic regional model id"
,
func
(
t
*
testing
.
T
)
{
account
:=
&
Account
{
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeBedrock
,
Credentials
:
map
[
string
]
any
{
"aws_region"
:
"us-east-1"
,
"aws_force_global"
:
"true"
,
"model_mapping"
:
map
[
string
]
any
{
"claude-sonnet-4-6"
:
"us.anthropic.claude-sonnet-4-6"
,
},
},
}
modelID
,
ok
:=
ResolveBedrockModelID
(
account
,
"claude-sonnet-4-6"
)
require
.
True
(
t
,
ok
)
assert
.
Equal
(
t
,
"global.anthropic.claude-sonnet-4-6"
,
modelID
)
})
t
.
Run
(
"direct bedrock model id passes through"
,
func
(
t
*
testing
.
T
)
{
account
:=
&
Account
{
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeBedrock
,
Credentials
:
map
[
string
]
any
{
"aws_region"
:
"us-east-1"
,
},
}
modelID
,
ok
:=
ResolveBedrockModelID
(
account
,
"anthropic.claude-haiku-4-5-20251001-v1:0"
)
require
.
True
(
t
,
ok
)
assert
.
Equal
(
t
,
"anthropic.claude-haiku-4-5-20251001-v1:0"
,
modelID
)
})
t
.
Run
(
"unsupported alias returns false"
,
func
(
t
*
testing
.
T
)
{
account
:=
&
Account
{
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeBedrock
,
Credentials
:
map
[
string
]
any
{
"aws_region"
:
"us-east-1"
,
},
}
_
,
ok
:=
ResolveBedrockModelID
(
account
,
"claude-3-5-sonnet-20241022"
)
assert
.
False
(
t
,
ok
)
})
}
func
TestAutoInjectBedrockBetaTokens
(
t
*
testing
.
T
)
{
t
.
Run
(
"inject interleaved-thinking when thinking present"
,
func
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[{"role":"user","content":"hi"}]}`
)
result
:=
autoInjectBedrockBetaTokens
(
nil
,
body
,
"us.anthropic.claude-opus-4-6-v1"
)
assert
.
Contains
(
t
,
result
,
"interleaved-thinking-2025-05-14"
)
})
t
.
Run
(
"no duplicate when already present"
,
func
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[{"role":"user","content":"hi"}]}`
)
result
:=
autoInjectBedrockBetaTokens
([]
string
{
"interleaved-thinking-2025-05-14"
},
body
,
"us.anthropic.claude-opus-4-6-v1"
)
count
:=
0
for
_
,
t
:=
range
result
{
if
t
==
"interleaved-thinking-2025-05-14"
{
count
++
}
}
assert
.
Equal
(
t
,
1
,
count
)
})
t
.
Run
(
"inject computer-use when computer tool present"
,
func
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"tools":[{"type":"computer_20250124","name":"computer","display_width_px":1024}],"messages":[{"role":"user","content":"hi"}]}`
)
result
:=
autoInjectBedrockBetaTokens
(
nil
,
body
,
"us.anthropic.claude-opus-4-6-v1"
)
assert
.
Contains
(
t
,
result
,
"computer-use-2025-11-24"
)
})
t
.
Run
(
"inject advanced-tool-use for programmatic tool calling"
,
func
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"tools":[{"name":"bash","allowed_callers":["code_execution_20250825"]}],"messages":[{"role":"user","content":"hi"}]}`
)
result
:=
autoInjectBedrockBetaTokens
(
nil
,
body
,
"us.anthropic.claude-opus-4-6-v1"
)
assert
.
Contains
(
t
,
result
,
"advanced-tool-use-2025-11-20"
)
})
t
.
Run
(
"inject advanced-tool-use for input examples"
,
func
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"tools":[{"name":"bash","input_examples":[{"cmd":"ls"}]}],"messages":[{"role":"user","content":"hi"}]}`
)
result
:=
autoInjectBedrockBetaTokens
(
nil
,
body
,
"us.anthropic.claude-opus-4-6-v1"
)
assert
.
Contains
(
t
,
result
,
"advanced-tool-use-2025-11-20"
)
})
t
.
Run
(
"inject tool-search-tool directly for pure tool search (no programmatic/inputExamples)"
,
func
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"tools":[{"type":"tool_search_tool_regex_20251119","name":"search"}],"messages":[{"role":"user","content":"hi"}]}`
)
result
:=
autoInjectBedrockBetaTokens
(
nil
,
body
,
"us.anthropic.claude-sonnet-4-6"
)
// 纯 tool search 场景直接注入 Bedrock 特定头,不走 advanced-tool-use 转换
assert
.
Contains
(
t
,
result
,
"tool-search-tool-2025-10-19"
)
assert
.
NotContains
(
t
,
result
,
"advanced-tool-use-2025-11-20"
)
})
t
.
Run
(
"inject advanced-tool-use when tool search combined with programmatic calling"
,
func
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"tools":[{"type":"tool_search_tool_regex_20251119","name":"search"},{"name":"bash","allowed_callers":["code_execution_20250825"]}],"messages":[{"role":"user","content":"hi"}]}`
)
result
:=
autoInjectBedrockBetaTokens
(
nil
,
body
,
"us.anthropic.claude-sonnet-4-6"
)
// 混合场景使用 advanced-tool-use(后续由 filter 转换为 tool-search-tool)
assert
.
Contains
(
t
,
result
,
"advanced-tool-use-2025-11-20"
)
})
t
.
Run
(
"do not inject tool-search beta for unsupported models"
,
func
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"tools":[{"type":"tool_search_tool_regex_20251119","name":"search"}],"messages":[{"role":"user","content":"hi"}]}`
)
result
:=
autoInjectBedrockBetaTokens
(
nil
,
body
,
"anthropic.claude-3-5-sonnet-20241022-v2:0"
)
assert
.
NotContains
(
t
,
result
,
"advanced-tool-use-2025-11-20"
)
assert
.
NotContains
(
t
,
result
,
"tool-search-tool-2025-10-19"
)
})
t
.
Run
(
"no injection for regular tools"
,
func
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"tools":[{"name":"bash","description":"run bash","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":"hi"}]}`
)
result
:=
autoInjectBedrockBetaTokens
(
nil
,
body
,
"us.anthropic.claude-opus-4-6-v1"
)
assert
.
Empty
(
t
,
result
)
})
t
.
Run
(
"no injection when no features detected"
,
func
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"messages":[{"role":"user","content":"hi"}],"max_tokens":100}`
)
result
:=
autoInjectBedrockBetaTokens
(
nil
,
body
,
"us.anthropic.claude-opus-4-6-v1"
)
assert
.
Empty
(
t
,
result
)
})
t
.
Run
(
"preserves existing tokens"
,
func
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"thinking":{"type":"enabled"},"messages":[{"role":"user","content":"hi"}]}`
)
existing
:=
[]
string
{
"context-1m-2025-08-07"
,
"compact-2026-01-12"
}
result
:=
autoInjectBedrockBetaTokens
(
existing
,
body
,
"us.anthropic.claude-opus-4-6-v1"
)
assert
.
Contains
(
t
,
result
,
"context-1m-2025-08-07"
)
assert
.
Contains
(
t
,
result
,
"compact-2026-01-12"
)
assert
.
Contains
(
t
,
result
,
"interleaved-thinking-2025-05-14"
)
})
}
func
TestResolveBedrockBetaTokens
(
t
*
testing
.
T
)
{
t
.
Run
(
"body-only tool features resolve to final bedrock tokens"
,
func
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"tools":[{"name":"bash","allowed_callers":["code_execution_20250825"]}],"messages":[{"role":"user","content":"hi"}]}`
)
result
:=
ResolveBedrockBetaTokens
(
""
,
body
,
"us.anthropic.claude-opus-4-6-v1"
)
assert
.
Contains
(
t
,
result
,
"tool-search-tool-2025-10-19"
)
assert
.
Contains
(
t
,
result
,
"tool-examples-2025-10-29"
)
})
t
.
Run
(
"unsupported client beta tokens are filtered out"
,
func
(
t
*
testing
.
T
)
{
body
:=
[]
byte
(
`{"messages":[{"role":"user","content":"hi"}]}`
)
result
:=
ResolveBedrockBetaTokens
(
"interleaved-thinking-2025-05-14,files-api-2025-04-14"
,
body
,
"us.anthropic.claude-opus-4-6-v1"
)
assert
.
Equal
(
t
,
[]
string
{
"interleaved-thinking-2025-05-14"
},
result
)
})
}
func
TestPrepareBedrockRequestBody_AutoBetaInjection
(
t
*
testing
.
T
)
{
t
.
Run
(
"thinking in body auto-injects beta without header"
,
func
(
t
*
testing
.
T
)
{
input
:=
`{"messages":[{"role":"user","content":"hi"}],"max_tokens":100,"thinking":{"type":"enabled","budget_tokens":10000}}`
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-opus-4-6-v1"
,
""
)
require
.
NoError
(
t
,
err
)
arr
:=
gjson
.
GetBytes
(
result
,
"anthropic_beta"
)
.
Array
()
found
:=
false
for
_
,
v
:=
range
arr
{
if
v
.
String
()
==
"interleaved-thinking-2025-05-14"
{
found
=
true
}
}
assert
.
True
(
t
,
found
,
"interleaved-thinking should be auto-injected"
)
})
t
.
Run
(
"header tokens merged with auto-injected tokens"
,
func
(
t
*
testing
.
T
)
{
input
:=
`{"messages":[{"role":"user","content":"hi"}],"max_tokens":100,"thinking":{"type":"enabled","budget_tokens":10000}}`
result
,
err
:=
PrepareBedrockRequestBody
([]
byte
(
input
),
"us.anthropic.claude-opus-4-6-v1"
,
"context-1m-2025-08-07"
)
require
.
NoError
(
t
,
err
)
arr
:=
gjson
.
GetBytes
(
result
,
"anthropic_beta"
)
.
Array
()
names
:=
make
([]
string
,
len
(
arr
))
for
i
,
v
:=
range
arr
{
names
[
i
]
=
v
.
String
()
}
assert
.
Contains
(
t
,
names
,
"context-1m-2025-08-07"
)
assert
.
Contains
(
t
,
names
,
"interleaved-thinking-2025-05-14"
)
})
}
func
TestAdjustBedrockModelRegionPrefix
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
modelID
string
region
string
expect
string
}{
// US region — no change needed
{
"us region keeps us prefix"
,
"us.anthropic.claude-opus-4-6-v1"
,
"us-east-1"
,
"us.anthropic.claude-opus-4-6-v1"
},
// EU region — replace us → eu
{
"eu region replaces prefix"
,
"us.anthropic.claude-opus-4-6-v1"
,
"eu-west-1"
,
"eu.anthropic.claude-opus-4-6-v1"
},
{
"eu region sonnet"
,
"us.anthropic.claude-sonnet-4-6"
,
"eu-central-1"
,
"eu.anthropic.claude-sonnet-4-6"
},
// APAC region — jp and au have dedicated prefixes per AWS docs
{
"jp region (ap-northeast-1)"
,
"us.anthropic.claude-sonnet-4-5-20250929-v1:0"
,
"ap-northeast-1"
,
"jp.anthropic.claude-sonnet-4-5-20250929-v1:0"
},
{
"au region (ap-southeast-2)"
,
"us.anthropic.claude-haiku-4-5-20251001-v1:0"
,
"ap-southeast-2"
,
"au.anthropic.claude-haiku-4-5-20251001-v1:0"
},
{
"apac region (ap-southeast-1)"
,
"us.anthropic.claude-sonnet-4-5-20250929-v1:0"
,
"ap-southeast-1"
,
"apac.anthropic.claude-sonnet-4-5-20250929-v1:0"
},
// eu → us (user manually set eu prefix, moved to us region)
{
"eu to us"
,
"eu.anthropic.claude-opus-4-6-v1"
,
"us-west-2"
,
"us.anthropic.claude-opus-4-6-v1"
},
// global prefix — replace to match region
{
"global to eu"
,
"global.anthropic.claude-opus-4-6-v1"
,
"eu-west-1"
,
"eu.anthropic.claude-opus-4-6-v1"
},
// No known prefix — leave unchanged
{
"no prefix unchanged"
,
"anthropic.claude-3-5-sonnet-20241022-v2:0"
,
"eu-west-1"
,
"anthropic.claude-3-5-sonnet-20241022-v2:0"
},
// GovCloud — uses independent us-gov prefix
{
"govcloud from us"
,
"us.anthropic.claude-opus-4-6-v1"
,
"us-gov-east-1"
,
"us-gov.anthropic.claude-opus-4-6-v1"
},
{
"govcloud already correct"
,
"us-gov.anthropic.claude-opus-4-6-v1"
,
"us-gov-west-1"
,
"us-gov.anthropic.claude-opus-4-6-v1"
},
// Force global (special region value)
{
"force global from us"
,
"us.anthropic.claude-opus-4-6-v1"
,
"global"
,
"global.anthropic.claude-opus-4-6-v1"
},
{
"force global from eu"
,
"eu.anthropic.claude-sonnet-4-6"
,
"global"
,
"global.anthropic.claude-sonnet-4-6"
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
assert
.
Equal
(
t
,
tt
.
expect
,
AdjustBedrockModelRegionPrefix
(
tt
.
modelID
,
tt
.
region
))
})
}
}
backend/internal/service/bedrock_signer.go
0 → 100644
View file @
e6d59216
package
service
import
(
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
v4
"github.com/aws/aws-sdk-go-v2/aws/signer/v4"
)
// BedrockSigner 使用 AWS SigV4 对 Bedrock 请求签名
type
BedrockSigner
struct
{
credentials
aws
.
Credentials
region
string
signer
*
v4
.
Signer
}
// NewBedrockSigner 创建 BedrockSigner
func
NewBedrockSigner
(
accessKeyID
,
secretAccessKey
,
sessionToken
,
region
string
)
*
BedrockSigner
{
return
&
BedrockSigner
{
credentials
:
aws
.
Credentials
{
AccessKeyID
:
accessKeyID
,
SecretAccessKey
:
secretAccessKey
,
SessionToken
:
sessionToken
,
},
region
:
region
,
signer
:
v4
.
NewSigner
(),
}
}
// NewBedrockSignerFromAccount 从 Account 凭证创建 BedrockSigner
func
NewBedrockSignerFromAccount
(
account
*
Account
)
(
*
BedrockSigner
,
error
)
{
accessKeyID
:=
account
.
GetCredential
(
"aws_access_key_id"
)
if
accessKeyID
==
""
{
return
nil
,
fmt
.
Errorf
(
"aws_access_key_id not found in credentials"
)
}
secretAccessKey
:=
account
.
GetCredential
(
"aws_secret_access_key"
)
if
secretAccessKey
==
""
{
return
nil
,
fmt
.
Errorf
(
"aws_secret_access_key not found in credentials"
)
}
region
:=
account
.
GetCredential
(
"aws_region"
)
if
region
==
""
{
region
=
defaultBedrockRegion
}
sessionToken
:=
account
.
GetCredential
(
"aws_session_token"
)
// 可选
return
NewBedrockSigner
(
accessKeyID
,
secretAccessKey
,
sessionToken
,
region
),
nil
}
// SignRequest 对 HTTP 请求进行 SigV4 签名
// 重要约束:调用此方法前,req 应只包含 AWS 相关的 header(如 Content-Type、Accept)。
// 非 AWS header(如 anthropic-beta)会参与签名计算,如果 Bedrock 服务端不识别这些 header,
// 签名验证可能失败。litellm 通过 _filter_headers_for_aws_signature 实现头过滤,
// 当前实现中 buildUpstreamRequestBedrock 仅设置了 Content-Type 和 Accept,因此是安全的。
func
(
s
*
BedrockSigner
)
SignRequest
(
ctx
context
.
Context
,
req
*
http
.
Request
,
body
[]
byte
)
error
{
payloadHash
:=
sha256Hash
(
body
)
return
s
.
signer
.
SignHTTP
(
ctx
,
s
.
credentials
,
req
,
payloadHash
,
"bedrock"
,
s
.
region
,
time
.
Now
())
}
func
sha256Hash
(
data
[]
byte
)
string
{
h
:=
sha256
.
Sum256
(
data
)
return
hex
.
EncodeToString
(
h
[
:
])
}
backend/internal/service/bedrock_signer_test.go
0 → 100644
View file @
e6d59216
package
service
import
(
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func
TestNewBedrockSignerFromAccount_DefaultRegion
(
t
*
testing
.
T
)
{
account
:=
&
Account
{
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeBedrock
,
Credentials
:
map
[
string
]
any
{
"aws_access_key_id"
:
"test-akid"
,
"aws_secret_access_key"
:
"test-secret"
,
},
}
signer
,
err
:=
NewBedrockSignerFromAccount
(
account
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
signer
)
assert
.
Equal
(
t
,
defaultBedrockRegion
,
signer
.
region
)
}
func
TestFilterBetaTokens
(
t
*
testing
.
T
)
{
tokens
:=
[]
string
{
"interleaved-thinking-2025-05-14"
,
"tool-search-tool-2025-10-19"
}
filterSet
:=
map
[
string
]
struct
{}{
"tool-search-tool-2025-10-19"
:
{},
}
assert
.
Equal
(
t
,
[]
string
{
"interleaved-thinking-2025-05-14"
},
filterBetaTokens
(
tokens
,
filterSet
))
assert
.
Equal
(
t
,
tokens
,
filterBetaTokens
(
tokens
,
nil
))
assert
.
Nil
(
t
,
filterBetaTokens
(
nil
,
filterSet
))
}
backend/internal/service/bedrock_stream.go
0 → 100644
View file @
e6d59216
package
service
import
(
"bufio"
"context"
"encoding/base64"
"errors"
"fmt"
"hash/crc32"
"io"
"net/http"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
// handleBedrockStreamingResponse 处理 Bedrock InvokeModelWithResponseStream 的 EventStream 响应
// Bedrock 返回 AWS EventStream 二进制格式,每个事件的 payload 中 chunk.bytes 是 base64 编码的
// Claude SSE 事件 JSON。本方法解码后转换为标准 SSE 格式写入客户端。
func
(
s
*
GatewayService
)
handleBedrockStreamingResponse
(
ctx
context
.
Context
,
resp
*
http
.
Response
,
c
*
gin
.
Context
,
account
*
Account
,
startTime
time
.
Time
,
model
string
,
)
(
*
streamingResult
,
error
)
{
w
:=
c
.
Writer
flusher
,
ok
:=
w
.
(
http
.
Flusher
)
if
!
ok
{
return
nil
,
errors
.
New
(
"streaming not supported"
)
}
c
.
Header
(
"Content-Type"
,
"text/event-stream"
)
c
.
Header
(
"Cache-Control"
,
"no-cache"
)
c
.
Header
(
"Connection"
,
"keep-alive"
)
c
.
Header
(
"X-Accel-Buffering"
,
"no"
)
if
v
:=
resp
.
Header
.
Get
(
"x-amzn-requestid"
);
v
!=
""
{
c
.
Header
(
"x-request-id"
,
v
)
}
usage
:=
&
ClaudeUsage
{}
var
firstTokenMs
*
int
clientDisconnected
:=
false
// Bedrock EventStream 使用 application/vnd.amazon.eventstream 二进制格式。
// 每个帧结构:total_length(4) + headers_length(4) + prelude_crc(4) + headers + payload + message_crc(4)
// 但更实用的方式是使用行扫描找 JSON chunks,因为 Bedrock 的响应在二进制帧中。
// 我们使用 EventStream decoder 来正确解析。
decoder
:=
newBedrockEventStreamDecoder
(
resp
.
Body
)
type
decodeEvent
struct
{
payload
[]
byte
err
error
}
events
:=
make
(
chan
decodeEvent
,
16
)
done
:=
make
(
chan
struct
{})
sendEvent
:=
func
(
ev
decodeEvent
)
bool
{
select
{
case
events
<-
ev
:
return
true
case
<-
done
:
return
false
}
}
var
lastReadAt
atomic
.
Int64
lastReadAt
.
Store
(
time
.
Now
()
.
UnixNano
())
go
func
()
{
defer
close
(
events
)
for
{
payload
,
err
:=
decoder
.
Decode
()
if
err
!=
nil
{
if
err
==
io
.
EOF
{
return
}
_
=
sendEvent
(
decodeEvent
{
err
:
err
})
return
}
lastReadAt
.
Store
(
time
.
Now
()
.
UnixNano
())
if
!
sendEvent
(
decodeEvent
{
payload
:
payload
})
{
return
}
}
}()
defer
close
(
done
)
streamInterval
:=
time
.
Duration
(
0
)
if
s
.
cfg
!=
nil
&&
s
.
cfg
.
Gateway
.
StreamDataIntervalTimeout
>
0
{
streamInterval
=
time
.
Duration
(
s
.
cfg
.
Gateway
.
StreamDataIntervalTimeout
)
*
time
.
Second
}
var
intervalTicker
*
time
.
Ticker
if
streamInterval
>
0
{
intervalTicker
=
time
.
NewTicker
(
streamInterval
)
defer
intervalTicker
.
Stop
()
}
var
intervalCh
<-
chan
time
.
Time
if
intervalTicker
!=
nil
{
intervalCh
=
intervalTicker
.
C
}
for
{
select
{
case
ev
,
ok
:=
<-
events
:
if
!
ok
{
if
!
clientDisconnected
{
flusher
.
Flush
()
}
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
,
clientDisconnect
:
clientDisconnected
},
nil
}
if
ev
.
err
!=
nil
{
if
clientDisconnected
{
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
,
clientDisconnect
:
true
},
nil
}
if
errors
.
Is
(
ev
.
err
,
context
.
Canceled
)
||
errors
.
Is
(
ev
.
err
,
context
.
DeadlineExceeded
)
{
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
,
clientDisconnect
:
true
},
nil
}
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
},
fmt
.
Errorf
(
"bedrock stream read error: %w"
,
ev
.
err
)
}
// payload 是 JSON,提取 chunk.bytes(base64 编码的 Claude SSE 事件数据)
sseData
:=
extractBedrockChunkData
(
ev
.
payload
)
if
sseData
==
nil
{
continue
}
if
firstTokenMs
==
nil
{
ms
:=
int
(
time
.
Since
(
startTime
)
.
Milliseconds
())
firstTokenMs
=
&
ms
}
// 转换 Bedrock 特有的 amazon-bedrock-invocationMetrics 为标准 Anthropic usage 格式
// 同时移除该字段避免透传给客户端
sseData
=
transformBedrockInvocationMetrics
(
sseData
)
// 解析 SSE 事件数据提取 usage
s
.
parseSSEUsagePassthrough
(
string
(
sseData
),
usage
)
// 确定 SSE event type
eventType
:=
gjson
.
GetBytes
(
sseData
,
"type"
)
.
String
()
// 写入标准 SSE 格式
if
!
clientDisconnected
{
var
writeErr
error
if
eventType
!=
""
{
_
,
writeErr
=
fmt
.
Fprintf
(
w
,
"event: %s
\n
data: %s
\n\n
"
,
eventType
,
sseData
)
}
else
{
_
,
writeErr
=
fmt
.
Fprintf
(
w
,
"data: %s
\n\n
"
,
sseData
)
}
if
writeErr
!=
nil
{
clientDisconnected
=
true
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Bedrock] Client disconnected during streaming, continue draining for usage: account=%d"
,
account
.
ID
)
}
else
{
flusher
.
Flush
()
}
}
case
<-
intervalCh
:
lastRead
:=
time
.
Unix
(
0
,
lastReadAt
.
Load
())
if
time
.
Since
(
lastRead
)
<
streamInterval
{
continue
}
if
clientDisconnected
{
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
,
clientDisconnect
:
true
},
nil
}
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Bedrock] Stream data interval timeout: account=%d model=%s interval=%s"
,
account
.
ID
,
model
,
streamInterval
)
if
s
.
rateLimitService
!=
nil
{
s
.
rateLimitService
.
HandleStreamTimeout
(
ctx
,
account
,
model
)
}
return
&
streamingResult
{
usage
:
usage
,
firstTokenMs
:
firstTokenMs
},
fmt
.
Errorf
(
"stream data interval timeout"
)
}
}
}
// extractBedrockChunkData 从 Bedrock EventStream payload 中提取 Claude SSE 事件数据
// Bedrock payload 格式:{"bytes":"<base64-encoded-json>"}
func
extractBedrockChunkData
(
payload
[]
byte
)
[]
byte
{
b64
:=
gjson
.
GetBytes
(
payload
,
"bytes"
)
.
String
()
if
b64
==
""
{
return
nil
}
decoded
,
err
:=
base64
.
StdEncoding
.
DecodeString
(
b64
)
if
err
!=
nil
{
return
nil
}
return
decoded
}
// transformBedrockInvocationMetrics 将 Bedrock 特有的 amazon-bedrock-invocationMetrics
// 转换为标准 Anthropic usage 格式,并从 SSE 数据中移除该字段。
//
// Bedrock Invoke 返回的 message_delta 事件可能包含:
//
// {"type":"message_delta","delta":{...},"amazon-bedrock-invocationMetrics":{"inputTokenCount":150,"outputTokenCount":42}}
//
// 转换为:
//
// {"type":"message_delta","delta":{...},"usage":{"input_tokens":150,"output_tokens":42}}
func
transformBedrockInvocationMetrics
(
data
[]
byte
)
[]
byte
{
metrics
:=
gjson
.
GetBytes
(
data
,
"amazon-bedrock-invocationMetrics"
)
if
!
metrics
.
Exists
()
||
!
metrics
.
IsObject
()
{
return
data
}
// 移除 Bedrock 特有字段
data
,
_
=
sjson
.
DeleteBytes
(
data
,
"amazon-bedrock-invocationMetrics"
)
// 如果已有标准 usage 字段,不覆盖
if
gjson
.
GetBytes
(
data
,
"usage"
)
.
Exists
()
{
return
data
}
// 转换 camelCase → snake_case 写入 usage
inputTokens
:=
metrics
.
Get
(
"inputTokenCount"
)
outputTokens
:=
metrics
.
Get
(
"outputTokenCount"
)
if
inputTokens
.
Exists
()
{
data
,
_
=
sjson
.
SetBytes
(
data
,
"usage.input_tokens"
,
inputTokens
.
Int
())
}
if
outputTokens
.
Exists
()
{
data
,
_
=
sjson
.
SetBytes
(
data
,
"usage.output_tokens"
,
outputTokens
.
Int
())
}
return
data
}
// bedrockEventStreamDecoder 解码 AWS EventStream 二进制帧
// EventStream 帧格式:
//
// [total_byte_length: 4 bytes]
// [headers_byte_length: 4 bytes]
// [prelude_crc: 4 bytes]
// [headers: variable]
// [payload: variable]
// [message_crc: 4 bytes]
type
bedrockEventStreamDecoder
struct
{
reader
*
bufio
.
Reader
}
func
newBedrockEventStreamDecoder
(
r
io
.
Reader
)
*
bedrockEventStreamDecoder
{
return
&
bedrockEventStreamDecoder
{
reader
:
bufio
.
NewReaderSize
(
r
,
64
*
1024
),
}
}
// Decode 读取下一个 EventStream 帧并返回 chunk 类型事件的 payload
func
(
d
*
bedrockEventStreamDecoder
)
Decode
()
([]
byte
,
error
)
{
for
{
// 读取 prelude: total_length(4) + headers_length(4) + prelude_crc(4) = 12 bytes
prelude
:=
make
([]
byte
,
12
)
if
_
,
err
:=
io
.
ReadFull
(
d
.
reader
,
prelude
);
err
!=
nil
{
return
nil
,
err
}
// 验证 prelude CRC(AWS EventStream 使用标准 CRC32 / IEEE)
preludeCRC
:=
bedrockReadUint32
(
prelude
[
8
:
12
])
if
crc32
.
Checksum
(
prelude
[
0
:
8
],
crc32IEEETable
)
!=
preludeCRC
{
return
nil
,
fmt
.
Errorf
(
"eventstream prelude CRC mismatch"
)
}
totalLength
:=
bedrockReadUint32
(
prelude
[
0
:
4
])
headersLength
:=
bedrockReadUint32
(
prelude
[
4
:
8
])
if
totalLength
<
16
{
// minimum: 12 prelude + 4 message_crc
return
nil
,
fmt
.
Errorf
(
"invalid eventstream frame: total_length=%d"
,
totalLength
)
}
// 读取 headers + payload + message_crc
remaining
:=
int
(
totalLength
)
-
12
if
remaining
<=
0
{
continue
}
data
:=
make
([]
byte
,
remaining
)
if
_
,
err
:=
io
.
ReadFull
(
d
.
reader
,
data
);
err
!=
nil
{
return
nil
,
err
}
// 验证 message CRC(覆盖 prelude + headers + payload)
messageCRC
:=
bedrockReadUint32
(
data
[
len
(
data
)
-
4
:
])
h
:=
crc32
.
New
(
crc32IEEETable
)
_
,
_
=
h
.
Write
(
prelude
)
_
,
_
=
h
.
Write
(
data
[
:
len
(
data
)
-
4
])
if
h
.
Sum32
()
!=
messageCRC
{
return
nil
,
fmt
.
Errorf
(
"eventstream message CRC mismatch"
)
}
// 解析 headers
headers
:=
data
[
:
headersLength
]
payload
:=
data
[
headersLength
:
len
(
data
)
-
4
]
// 去掉 message_crc
// 从 headers 中提取 :event-type
eventType
:=
extractEventStreamHeaderValue
(
headers
,
":event-type"
)
// 只处理 chunk 事件
if
eventType
==
"chunk"
{
// payload 是完整的 JSON,包含 bytes 字段
return
payload
,
nil
}
// 检查异常事件
exceptionType
:=
extractEventStreamHeaderValue
(
headers
,
":exception-type"
)
if
exceptionType
!=
""
{
return
nil
,
fmt
.
Errorf
(
"bedrock exception: %s: %s"
,
exceptionType
,
string
(
payload
))
}
messageType
:=
extractEventStreamHeaderValue
(
headers
,
":message-type"
)
if
messageType
==
"exception"
||
messageType
==
"error"
{
return
nil
,
fmt
.
Errorf
(
"bedrock error: %s"
,
string
(
payload
))
}
// 跳过其他事件类型(如 initial-response)
}
}
// extractEventStreamHeaderValue 从 EventStream headers 二进制数据中提取指定 header 的字符串值
// EventStream header 格式:
//
// [name_length: 1 byte][name: variable][value_type: 1 byte][value: variable]
//
// value_type = 7 表示 string 类型,前 2 bytes 为长度
func
extractEventStreamHeaderValue
(
headers
[]
byte
,
targetName
string
)
string
{
pos
:=
0
for
pos
<
len
(
headers
)
{
if
pos
>=
len
(
headers
)
{
break
}
nameLen
:=
int
(
headers
[
pos
])
pos
++
if
pos
+
nameLen
>
len
(
headers
)
{
break
}
name
:=
string
(
headers
[
pos
:
pos
+
nameLen
])
pos
+=
nameLen
if
pos
>=
len
(
headers
)
{
break
}
valueType
:=
headers
[
pos
]
pos
++
switch
valueType
{
case
7
:
// string
if
pos
+
2
>
len
(
headers
)
{
return
""
}
valueLen
:=
int
(
bedrockReadUint16
(
headers
[
pos
:
pos
+
2
]))
pos
+=
2
if
pos
+
valueLen
>
len
(
headers
)
{
return
""
}
value
:=
string
(
headers
[
pos
:
pos
+
valueLen
])
pos
+=
valueLen
if
name
==
targetName
{
return
value
}
case
0
:
// bool true
if
name
==
targetName
{
return
"true"
}
case
1
:
// bool false
if
name
==
targetName
{
return
"false"
}
case
2
:
// byte
pos
++
if
name
==
targetName
{
return
""
}
case
3
:
// short
pos
+=
2
if
name
==
targetName
{
return
""
}
case
4
:
// int
pos
+=
4
if
name
==
targetName
{
return
""
}
case
5
:
// long
pos
+=
8
if
name
==
targetName
{
return
""
}
case
6
:
// bytes
if
pos
+
2
>
len
(
headers
)
{
return
""
}
valueLen
:=
int
(
bedrockReadUint16
(
headers
[
pos
:
pos
+
2
]))
pos
+=
2
+
valueLen
case
8
:
// timestamp
pos
+=
8
case
9
:
// uuid
pos
+=
16
default
:
return
""
// 未知类型,无法继续解析
}
}
return
""
}
// crc32IEEETable is the CRC32 / IEEE table used by AWS EventStream.
var
crc32IEEETable
=
crc32
.
MakeTable
(
crc32
.
IEEE
)
func
bedrockReadUint32
(
b
[]
byte
)
uint32
{
return
uint32
(
b
[
0
])
<<
24
|
uint32
(
b
[
1
])
<<
16
|
uint32
(
b
[
2
])
<<
8
|
uint32
(
b
[
3
])
}
func
bedrockReadUint16
(
b
[]
byte
)
uint16
{
return
uint16
(
b
[
0
])
<<
8
|
uint16
(
b
[
1
])
}
backend/internal/service/bedrock_stream_test.go
0 → 100644
View file @
e6d59216
package
service
import
(
"bytes"
"encoding/base64"
"encoding/binary"
"hash/crc32"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func
TestExtractBedrockChunkData
(
t
*
testing
.
T
)
{
t
.
Run
(
"valid base64 payload"
,
func
(
t
*
testing
.
T
)
{
original
:=
`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}`
b64
:=
base64
.
StdEncoding
.
EncodeToString
([]
byte
(
original
))
payload
:=
[]
byte
(
`{"bytes":"`
+
b64
+
`"}`
)
result
:=
extractBedrockChunkData
(
payload
)
require
.
NotNil
(
t
,
result
)
assert
.
JSONEq
(
t
,
original
,
string
(
result
))
})
t
.
Run
(
"empty bytes field"
,
func
(
t
*
testing
.
T
)
{
result
:=
extractBedrockChunkData
([]
byte
(
`{"bytes":""}`
))
assert
.
Nil
(
t
,
result
)
})
t
.
Run
(
"no bytes field"
,
func
(
t
*
testing
.
T
)
{
result
:=
extractBedrockChunkData
([]
byte
(
`{"other":"value"}`
))
assert
.
Nil
(
t
,
result
)
})
t
.
Run
(
"invalid base64"
,
func
(
t
*
testing
.
T
)
{
result
:=
extractBedrockChunkData
([]
byte
(
`{"bytes":"not-valid-base64!!!"}`
))
assert
.
Nil
(
t
,
result
)
})
}
func
TestTransformBedrockInvocationMetrics
(
t
*
testing
.
T
)
{
t
.
Run
(
"converts metrics to usage"
,
func
(
t
*
testing
.
T
)
{
input
:=
`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"amazon-bedrock-invocationMetrics":{"inputTokenCount":150,"outputTokenCount":42}}`
result
:=
transformBedrockInvocationMetrics
([]
byte
(
input
))
// amazon-bedrock-invocationMetrics should be removed
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"amazon-bedrock-invocationMetrics"
)
.
Exists
())
// usage should be set
assert
.
Equal
(
t
,
int64
(
150
),
gjson
.
GetBytes
(
result
,
"usage.input_tokens"
)
.
Int
())
assert
.
Equal
(
t
,
int64
(
42
),
gjson
.
GetBytes
(
result
,
"usage.output_tokens"
)
.
Int
())
// original fields preserved
assert
.
Equal
(
t
,
"message_delta"
,
gjson
.
GetBytes
(
result
,
"type"
)
.
String
())
assert
.
Equal
(
t
,
"end_turn"
,
gjson
.
GetBytes
(
result
,
"delta.stop_reason"
)
.
String
())
})
t
.
Run
(
"no metrics present"
,
func
(
t
*
testing
.
T
)
{
input
:=
`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}`
result
:=
transformBedrockInvocationMetrics
([]
byte
(
input
))
assert
.
JSONEq
(
t
,
input
,
string
(
result
))
})
t
.
Run
(
"does not overwrite existing usage"
,
func
(
t
*
testing
.
T
)
{
input
:=
`{"type":"message_delta","usage":{"output_tokens":100},"amazon-bedrock-invocationMetrics":{"inputTokenCount":150,"outputTokenCount":42}}`
result
:=
transformBedrockInvocationMetrics
([]
byte
(
input
))
// metrics removed but existing usage preserved
assert
.
False
(
t
,
gjson
.
GetBytes
(
result
,
"amazon-bedrock-invocationMetrics"
)
.
Exists
())
assert
.
Equal
(
t
,
int64
(
100
),
gjson
.
GetBytes
(
result
,
"usage.output_tokens"
)
.
Int
())
})
}
func
TestExtractEventStreamHeaderValue
(
t
*
testing
.
T
)
{
// Build a header with :event-type = "chunk" (string type = 7)
buildStringHeader
:=
func
(
name
,
value
string
)
[]
byte
{
var
buf
bytes
.
Buffer
// name length (1 byte)
_
=
buf
.
WriteByte
(
byte
(
len
(
name
)))
// name
_
,
_
=
buf
.
WriteString
(
name
)
// value type (7 = string)
_
=
buf
.
WriteByte
(
7
)
// value length (2 bytes, big-endian)
_
=
binary
.
Write
(
&
buf
,
binary
.
BigEndian
,
uint16
(
len
(
value
)))
// value
_
,
_
=
buf
.
WriteString
(
value
)
return
buf
.
Bytes
()
}
t
.
Run
(
"find string header"
,
func
(
t
*
testing
.
T
)
{
headers
:=
buildStringHeader
(
":event-type"
,
"chunk"
)
assert
.
Equal
(
t
,
"chunk"
,
extractEventStreamHeaderValue
(
headers
,
":event-type"
))
})
t
.
Run
(
"header not found"
,
func
(
t
*
testing
.
T
)
{
headers
:=
buildStringHeader
(
":event-type"
,
"chunk"
)
assert
.
Equal
(
t
,
""
,
extractEventStreamHeaderValue
(
headers
,
":message-type"
))
})
t
.
Run
(
"multiple headers"
,
func
(
t
*
testing
.
T
)
{
var
buf
bytes
.
Buffer
_
,
_
=
buf
.
Write
(
buildStringHeader
(
":content-type"
,
"application/json"
))
_
,
_
=
buf
.
Write
(
buildStringHeader
(
":event-type"
,
"chunk"
))
_
,
_
=
buf
.
Write
(
buildStringHeader
(
":message-type"
,
"event"
))
headers
:=
buf
.
Bytes
()
assert
.
Equal
(
t
,
"chunk"
,
extractEventStreamHeaderValue
(
headers
,
":event-type"
))
assert
.
Equal
(
t
,
"application/json"
,
extractEventStreamHeaderValue
(
headers
,
":content-type"
))
assert
.
Equal
(
t
,
"event"
,
extractEventStreamHeaderValue
(
headers
,
":message-type"
))
})
t
.
Run
(
"empty headers"
,
func
(
t
*
testing
.
T
)
{
assert
.
Equal
(
t
,
""
,
extractEventStreamHeaderValue
([]
byte
{},
":event-type"
))
})
}
func
TestBedrockEventStreamDecoder
(
t
*
testing
.
T
)
{
crc32IeeeTab
:=
crc32
.
MakeTable
(
crc32
.
IEEE
)
// Build a valid EventStream frame with correct CRC32/IEEE checksums.
buildFrame
:=
func
(
eventType
string
,
payload
[]
byte
)
[]
byte
{
// Build headers
var
headersBuf
bytes
.
Buffer
// :event-type header
_
=
headersBuf
.
WriteByte
(
byte
(
len
(
":event-type"
)))
_
,
_
=
headersBuf
.
WriteString
(
":event-type"
)
_
=
headersBuf
.
WriteByte
(
7
)
// string type
_
=
binary
.
Write
(
&
headersBuf
,
binary
.
BigEndian
,
uint16
(
len
(
eventType
)))
_
,
_
=
headersBuf
.
WriteString
(
eventType
)
// :message-type header
_
=
headersBuf
.
WriteByte
(
byte
(
len
(
":message-type"
)))
_
,
_
=
headersBuf
.
WriteString
(
":message-type"
)
_
=
headersBuf
.
WriteByte
(
7
)
_
=
binary
.
Write
(
&
headersBuf
,
binary
.
BigEndian
,
uint16
(
len
(
"event"
)))
_
,
_
=
headersBuf
.
WriteString
(
"event"
)
headers
:=
headersBuf
.
Bytes
()
headersLen
:=
uint32
(
len
(
headers
))
// total = 12 (prelude) + headers + payload + 4 (message_crc)
totalLen
:=
uint32
(
12
+
len
(
headers
)
+
len
(
payload
)
+
4
)
// Prelude: total_length(4) + headers_length(4)
var
preludeBuf
bytes
.
Buffer
_
=
binary
.
Write
(
&
preludeBuf
,
binary
.
BigEndian
,
totalLen
)
_
=
binary
.
Write
(
&
preludeBuf
,
binary
.
BigEndian
,
headersLen
)
preludeBytes
:=
preludeBuf
.
Bytes
()
preludeCRC
:=
crc32
.
Checksum
(
preludeBytes
,
crc32IeeeTab
)
// Build frame: prelude + prelude_crc + headers + payload
var
frame
bytes
.
Buffer
_
,
_
=
frame
.
Write
(
preludeBytes
)
_
=
binary
.
Write
(
&
frame
,
binary
.
BigEndian
,
preludeCRC
)
_
,
_
=
frame
.
Write
(
headers
)
_
,
_
=
frame
.
Write
(
payload
)
// Message CRC covers everything before itself
messageCRC
:=
crc32
.
Checksum
(
frame
.
Bytes
(),
crc32IeeeTab
)
_
=
binary
.
Write
(
&
frame
,
binary
.
BigEndian
,
messageCRC
)
return
frame
.
Bytes
()
}
t
.
Run
(
"decode chunk event"
,
func
(
t
*
testing
.
T
)
{
payload
:=
[]
byte
(
`{"bytes":"dGVzdA=="}`
)
// base64("test")
frame
:=
buildFrame
(
"chunk"
,
payload
)
decoder
:=
newBedrockEventStreamDecoder
(
bytes
.
NewReader
(
frame
))
result
,
err
:=
decoder
.
Decode
()
require
.
NoError
(
t
,
err
)
assert
.
Equal
(
t
,
payload
,
result
)
})
t
.
Run
(
"skip non-chunk events"
,
func
(
t
*
testing
.
T
)
{
// Write initial-response followed by chunk
var
buf
bytes
.
Buffer
_
,
_
=
buf
.
Write
(
buildFrame
(
"initial-response"
,
[]
byte
(
`{}`
)))
chunkPayload
:=
[]
byte
(
`{"bytes":"aGVsbG8="}`
)
_
,
_
=
buf
.
Write
(
buildFrame
(
"chunk"
,
chunkPayload
))
decoder
:=
newBedrockEventStreamDecoder
(
&
buf
)
result
,
err
:=
decoder
.
Decode
()
require
.
NoError
(
t
,
err
)
assert
.
Equal
(
t
,
chunkPayload
,
result
)
})
t
.
Run
(
"EOF on empty input"
,
func
(
t
*
testing
.
T
)
{
decoder
:=
newBedrockEventStreamDecoder
(
bytes
.
NewReader
(
nil
))
_
,
err
:=
decoder
.
Decode
()
assert
.
Equal
(
t
,
io
.
EOF
,
err
)
})
t
.
Run
(
"corrupted prelude CRC"
,
func
(
t
*
testing
.
T
)
{
frame
:=
buildFrame
(
"chunk"
,
[]
byte
(
`{"bytes":"dGVzdA=="}`
))
// Corrupt the prelude CRC (bytes 8-11)
frame
[
8
]
^=
0xFF
decoder
:=
newBedrockEventStreamDecoder
(
bytes
.
NewReader
(
frame
))
_
,
err
:=
decoder
.
Decode
()
require
.
Error
(
t
,
err
)
assert
.
Contains
(
t
,
err
.
Error
(),
"prelude CRC mismatch"
)
})
t
.
Run
(
"corrupted message CRC"
,
func
(
t
*
testing
.
T
)
{
frame
:=
buildFrame
(
"chunk"
,
[]
byte
(
`{"bytes":"dGVzdA=="}`
))
// Corrupt the message CRC (last 4 bytes)
frame
[
len
(
frame
)
-
1
]
^=
0xFF
decoder
:=
newBedrockEventStreamDecoder
(
bytes
.
NewReader
(
frame
))
_
,
err
:=
decoder
.
Decode
()
require
.
Error
(
t
,
err
)
assert
.
Contains
(
t
,
err
.
Error
(),
"message CRC mismatch"
)
})
t
.
Run
(
"castagnoli encoded frame is rejected"
,
func
(
t
*
testing
.
T
)
{
castagnoliTab
:=
crc32
.
MakeTable
(
crc32
.
Castagnoli
)
payload
:=
[]
byte
(
`{"bytes":"dGVzdA=="}`
)
var
headersBuf
bytes
.
Buffer
_
=
headersBuf
.
WriteByte
(
byte
(
len
(
":event-type"
)))
_
,
_
=
headersBuf
.
WriteString
(
":event-type"
)
_
=
headersBuf
.
WriteByte
(
7
)
_
=
binary
.
Write
(
&
headersBuf
,
binary
.
BigEndian
,
uint16
(
len
(
"chunk"
)))
_
,
_
=
headersBuf
.
WriteString
(
"chunk"
)
headers
:=
headersBuf
.
Bytes
()
headersLen
:=
uint32
(
len
(
headers
))
totalLen
:=
uint32
(
12
+
len
(
headers
)
+
len
(
payload
)
+
4
)
var
preludeBuf
bytes
.
Buffer
_
=
binary
.
Write
(
&
preludeBuf
,
binary
.
BigEndian
,
totalLen
)
_
=
binary
.
Write
(
&
preludeBuf
,
binary
.
BigEndian
,
headersLen
)
preludeBytes
:=
preludeBuf
.
Bytes
()
var
frame
bytes
.
Buffer
_
,
_
=
frame
.
Write
(
preludeBytes
)
_
=
binary
.
Write
(
&
frame
,
binary
.
BigEndian
,
crc32
.
Checksum
(
preludeBytes
,
castagnoliTab
))
_
,
_
=
frame
.
Write
(
headers
)
_
,
_
=
frame
.
Write
(
payload
)
_
=
binary
.
Write
(
&
frame
,
binary
.
BigEndian
,
crc32
.
Checksum
(
frame
.
Bytes
(),
castagnoliTab
))
decoder
:=
newBedrockEventStreamDecoder
(
bytes
.
NewReader
(
frame
.
Bytes
()))
_
,
err
:=
decoder
.
Decode
()
require
.
Error
(
t
,
err
)
assert
.
Contains
(
t
,
err
.
Error
(),
"prelude CRC mismatch"
)
})
}
func
TestBuildBedrockURL
(
t
*
testing
.
T
)
{
t
.
Run
(
"stream URL with colon in model ID"
,
func
(
t
*
testing
.
T
)
{
url
:=
BuildBedrockURL
(
"us-east-1"
,
"us.anthropic.claude-opus-4-5-20251101-v1:0"
,
true
)
assert
.
Equal
(
t
,
"https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-opus-4-5-20251101-v1%3A0/invoke-with-response-stream"
,
url
)
})
t
.
Run
(
"non-stream URL with colon in model ID"
,
func
(
t
*
testing
.
T
)
{
url
:=
BuildBedrockURL
(
"eu-west-1"
,
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0"
,
false
)
assert
.
Equal
(
t
,
"https://bedrock-runtime.eu-west-1.amazonaws.com/model/eu.anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke"
,
url
)
})
t
.
Run
(
"model ID without colon"
,
func
(
t
*
testing
.
T
)
{
url
:=
BuildBedrockURL
(
"us-east-1"
,
"us.anthropic.claude-sonnet-4-6"
,
true
)
assert
.
Equal
(
t
,
"https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-sonnet-4-6/invoke-with-response-stream"
,
url
)
})
}
backend/internal/service/domain_constants.go
View file @
e6d59216
...
...
@@ -33,6 +33,8 @@ const (
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 签名连接 Bedrock)
AccountTypeBedrockAPIKey
=
domain
.
AccountTypeBedrockAPIKey
// AWS Bedrock API Key 类型账号(通过 Bearer Token 连接 Bedrock)
)
// Redeem type constants
...
...
backend/internal/service/gateway_service.go
View file @
e6d59216
...
...
@@ -3370,6 +3370,10 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo
if
account
.
Platform
==
PlatformSora
{
return
s
.
isSoraModelSupportedByAccount
(
account
,
requestedModel
)
}
if
account
.
IsBedrock
()
{
_
,
ok
:=
ResolveBedrockModelID
(
account
,
requestedModel
)
return
ok
}
// OAuth/SetupToken 账号使用 Anthropic 标准映射(短ID → 长ID)
if
account
.
Platform
==
PlatformAnthropic
&&
account
.
Type
!=
AccountTypeAPIKey
{
requestedModel
=
claude
.
NormalizeModelID
(
requestedModel
)
...
...
@@ -3527,6 +3531,10 @@ func (s *GatewayService) GetAccessToken(ctx context.Context, account *Account) (
return
""
,
""
,
errors
.
New
(
"api_key not found in credentials"
)
}
return
apiKey
,
"apikey"
,
nil
case
AccountTypeBedrock
:
return
""
,
"bedrock"
,
nil
// Bedrock 使用 SigV4 签名,不需要 token
case
AccountTypeBedrockAPIKey
:
return
""
,
"bedrock-apikey"
,
nil
// Bedrock API Key 使用 Bearer Token,由 forwardBedrock 处理
default
:
return
""
,
""
,
fmt
.
Errorf
(
"unsupported account type: %s"
,
account
.
Type
)
}
...
...
@@ -3982,6 +3990,10 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
return
s
.
forwardAnthropicAPIKeyPassthrough
(
ctx
,
c
,
account
,
passthroughBody
,
passthroughModel
,
parsed
.
Stream
,
startTime
)
}
if
account
!=
nil
&&
account
.
IsBedrock
()
{
return
s
.
forwardBedrock
(
ctx
,
c
,
account
,
parsed
,
startTime
)
}
// Beta policy: evaluate once; block check + cache filter set for buildUpstreamRequest.
// Always overwrite the cache to prevent stale values from a previous retry with a different account.
if
account
.
Platform
==
PlatformAnthropic
&&
c
!=
nil
{
...
...
@@ -5123,6 +5135,366 @@ func writeAnthropicPassthroughResponseHeaders(dst http.Header, src http.Header,
}
}
// forwardBedrock 转发请求到 AWS Bedrock
func
(
s
*
GatewayService
)
forwardBedrock
(
ctx
context
.
Context
,
c
*
gin
.
Context
,
account
*
Account
,
parsed
*
ParsedRequest
,
startTime
time
.
Time
,
)
(
*
ForwardResult
,
error
)
{
reqModel
:=
parsed
.
Model
reqStream
:=
parsed
.
Stream
body
:=
parsed
.
Body
region
:=
bedrockRuntimeRegion
(
account
)
mappedModel
,
ok
:=
ResolveBedrockModelID
(
account
,
reqModel
)
if
!
ok
{
return
nil
,
fmt
.
Errorf
(
"unsupported bedrock model: %s"
,
reqModel
)
}
if
mappedModel
!=
reqModel
{
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Bedrock] Model mapping: %s -> %s (account: %s)"
,
reqModel
,
mappedModel
,
account
.
Name
)
}
betaHeader
:=
""
if
c
!=
nil
&&
c
.
Request
!=
nil
{
betaHeader
=
c
.
GetHeader
(
"anthropic-beta"
)
}
// 准备请求体(注入 anthropic_version/anthropic_beta,移除 Bedrock 不支持的字段,清理 cache_control)
betaTokens
,
err
:=
s
.
resolveBedrockBetaTokensForRequest
(
ctx
,
account
,
betaHeader
,
body
,
mappedModel
)
if
err
!=
nil
{
return
nil
,
err
}
bedrockBody
,
err
:=
PrepareBedrockRequestBodyWithTokens
(
body
,
mappedModel
,
betaTokens
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"prepare bedrock request body: %w"
,
err
)
}
proxyURL
:=
""
if
account
.
ProxyID
!=
nil
&&
account
.
Proxy
!=
nil
{
proxyURL
=
account
.
Proxy
.
URL
()
}
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Bedrock] 命中 Bedrock 分支: account=%d name=%s model=%s->%s stream=%v"
,
account
.
ID
,
account
.
Name
,
reqModel
,
mappedModel
,
reqStream
)
// 根据账号类型选择认证方式
var
signer
*
BedrockSigner
var
bedrockAPIKey
string
if
account
.
IsBedrockAPIKey
()
{
bedrockAPIKey
=
account
.
GetCredential
(
"api_key"
)
if
bedrockAPIKey
==
""
{
return
nil
,
fmt
.
Errorf
(
"api_key not found in bedrock-apikey credentials"
)
}
}
else
{
signer
,
err
=
NewBedrockSignerFromAccount
(
account
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"create bedrock signer: %w"
,
err
)
}
}
// 执行上游请求(含重试)
resp
,
err
:=
s
.
executeBedrockUpstream
(
ctx
,
c
,
account
,
bedrockBody
,
mappedModel
,
region
,
reqStream
,
signer
,
bedrockAPIKey
,
proxyURL
)
if
err
!=
nil
{
return
nil
,
err
}
defer
func
()
{
_
=
resp
.
Body
.
Close
()
}()
// 将 Bedrock 的 x-amzn-requestid 映射到 x-request-id,
// 使通用错误处理函数(handleErrorResponse、handleRetryExhaustedError)能正确提取 AWS request ID。
if
awsReqID
:=
resp
.
Header
.
Get
(
"x-amzn-requestid"
);
awsReqID
!=
""
&&
resp
.
Header
.
Get
(
"x-request-id"
)
==
""
{
resp
.
Header
.
Set
(
"x-request-id"
,
awsReqID
)
}
// 错误/failover 处理
if
resp
.
StatusCode
>=
400
{
return
s
.
handleBedrockUpstreamErrors
(
ctx
,
resp
,
c
,
account
)
}
// 响应处理
var
usage
*
ClaudeUsage
var
firstTokenMs
*
int
var
clientDisconnect
bool
if
reqStream
{
streamResult
,
err
:=
s
.
handleBedrockStreamingResponse
(
ctx
,
resp
,
c
,
account
,
startTime
,
reqModel
)
if
err
!=
nil
{
return
nil
,
err
}
usage
=
streamResult
.
usage
firstTokenMs
=
streamResult
.
firstTokenMs
clientDisconnect
=
streamResult
.
clientDisconnect
}
else
{
usage
,
err
=
s
.
handleBedrockNonStreamingResponse
(
ctx
,
resp
,
c
,
account
)
if
err
!=
nil
{
return
nil
,
err
}
}
if
usage
==
nil
{
usage
=
&
ClaudeUsage
{}
}
return
&
ForwardResult
{
RequestID
:
resp
.
Header
.
Get
(
"x-amzn-requestid"
),
Usage
:
*
usage
,
Model
:
reqModel
,
Stream
:
reqStream
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
ClientDisconnect
:
clientDisconnect
,
},
nil
}
// executeBedrockUpstream 执行 Bedrock 上游请求(含重试逻辑)
func
(
s
*
GatewayService
)
executeBedrockUpstream
(
ctx
context
.
Context
,
c
*
gin
.
Context
,
account
*
Account
,
body
[]
byte
,
modelID
string
,
region
string
,
stream
bool
,
signer
*
BedrockSigner
,
apiKey
string
,
proxyURL
string
,
)
(
*
http
.
Response
,
error
)
{
var
resp
*
http
.
Response
var
err
error
retryStart
:=
time
.
Now
()
for
attempt
:=
1
;
attempt
<=
maxRetryAttempts
;
attempt
++
{
var
upstreamReq
*
http
.
Request
if
account
.
IsBedrockAPIKey
()
{
upstreamReq
,
err
=
s
.
buildUpstreamRequestBedrockAPIKey
(
ctx
,
body
,
modelID
,
region
,
stream
,
apiKey
)
}
else
{
upstreamReq
,
err
=
s
.
buildUpstreamRequestBedrock
(
ctx
,
body
,
modelID
,
region
,
stream
,
signer
)
}
if
err
!=
nil
{
return
nil
,
err
}
resp
,
err
=
s
.
httpUpstream
.
DoWithTLS
(
upstreamReq
,
proxyURL
,
account
.
ID
,
account
.
Concurrency
,
false
)
if
err
!=
nil
{
if
resp
!=
nil
&&
resp
.
Body
!=
nil
{
_
=
resp
.
Body
.
Close
()
}
safeErr
:=
sanitizeUpstreamErrorMessage
(
err
.
Error
())
setOpsUpstreamError
(
c
,
0
,
safeErr
,
""
)
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
AccountID
:
account
.
ID
,
AccountName
:
account
.
Name
,
UpstreamStatusCode
:
0
,
Kind
:
"request_error"
,
Message
:
safeErr
,
})
c
.
JSON
(
http
.
StatusBadGateway
,
gin
.
H
{
"type"
:
"error"
,
"error"
:
gin
.
H
{
"type"
:
"upstream_error"
,
"message"
:
"Upstream request failed"
,
},
})
return
nil
,
fmt
.
Errorf
(
"upstream request failed: %s"
,
safeErr
)
}
if
resp
.
StatusCode
>=
400
&&
resp
.
StatusCode
!=
400
&&
s
.
shouldRetryUpstreamError
(
account
,
resp
.
StatusCode
)
{
if
attempt
<
maxRetryAttempts
{
elapsed
:=
time
.
Since
(
retryStart
)
if
elapsed
>=
maxRetryElapsed
{
break
}
delay
:=
retryBackoffDelay
(
attempt
)
remaining
:=
maxRetryElapsed
-
elapsed
if
delay
>
remaining
{
delay
=
remaining
}
if
delay
<=
0
{
break
}
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
_
=
resp
.
Body
.
Close
()
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
AccountID
:
account
.
ID
,
AccountName
:
account
.
Name
,
UpstreamStatusCode
:
resp
.
StatusCode
,
Kind
:
"retry"
,
Message
:
extractUpstreamErrorMessage
(
respBody
),
Detail
:
func
()
string
{
if
s
.
cfg
!=
nil
&&
s
.
cfg
.
Gateway
.
LogUpstreamErrorBody
{
return
truncateString
(
string
(
respBody
),
s
.
cfg
.
Gateway
.
LogUpstreamErrorBodyMaxBytes
)
}
return
""
}(),
})
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Bedrock] account %d: upstream error %d, retry %d/%d after %v"
,
account
.
ID
,
resp
.
StatusCode
,
attempt
,
maxRetryAttempts
,
delay
)
if
err
:=
sleepWithContext
(
ctx
,
delay
);
err
!=
nil
{
return
nil
,
err
}
continue
}
break
}
break
}
if
resp
==
nil
||
resp
.
Body
==
nil
{
return
nil
,
errors
.
New
(
"upstream request failed: empty response"
)
}
return
resp
,
nil
}
// handleBedrockUpstreamErrors 处理 Bedrock 上游 4xx/5xx 错误(failover + 错误响应)
func
(
s
*
GatewayService
)
handleBedrockUpstreamErrors
(
ctx
context
.
Context
,
resp
*
http
.
Response
,
c
*
gin
.
Context
,
account
*
Account
,
)
(
*
ForwardResult
,
error
)
{
// retry exhausted + failover
if
s
.
shouldRetryUpstreamError
(
account
,
resp
.
StatusCode
)
{
if
s
.
shouldFailoverUpstreamError
(
resp
.
StatusCode
)
{
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
_
=
resp
.
Body
.
Close
()
resp
.
Body
=
io
.
NopCloser
(
bytes
.
NewReader
(
respBody
))
logger
.
LegacyPrintf
(
"service.gateway"
,
"[Bedrock] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d Body=%s"
,
account
.
ID
,
account
.
Name
,
resp
.
StatusCode
,
truncateString
(
string
(
respBody
),
1000
))
s
.
handleRetryExhaustedSideEffects
(
ctx
,
resp
,
account
)
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
AccountID
:
account
.
ID
,
AccountName
:
account
.
Name
,
UpstreamStatusCode
:
resp
.
StatusCode
,
Kind
:
"retry_exhausted_failover"
,
Message
:
extractUpstreamErrorMessage
(
respBody
),
})
return
nil
,
&
UpstreamFailoverError
{
StatusCode
:
resp
.
StatusCode
,
ResponseBody
:
respBody
,
}
}
return
s
.
handleRetryExhaustedError
(
ctx
,
resp
,
c
,
account
)
}
// non-retryable failover
if
s
.
shouldFailoverUpstreamError
(
resp
.
StatusCode
)
{
respBody
,
_
:=
io
.
ReadAll
(
io
.
LimitReader
(
resp
.
Body
,
2
<<
20
))
_
=
resp
.
Body
.
Close
()
resp
.
Body
=
io
.
NopCloser
(
bytes
.
NewReader
(
respBody
))
s
.
handleFailoverSideEffects
(
ctx
,
resp
,
account
)
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
AccountID
:
account
.
ID
,
AccountName
:
account
.
Name
,
UpstreamStatusCode
:
resp
.
StatusCode
,
Kind
:
"failover"
,
Message
:
extractUpstreamErrorMessage
(
respBody
),
})
return
nil
,
&
UpstreamFailoverError
{
StatusCode
:
resp
.
StatusCode
,
ResponseBody
:
respBody
,
}
}
// other errors
return
s
.
handleErrorResponse
(
ctx
,
resp
,
c
,
account
)
}
// buildUpstreamRequestBedrock 构建 Bedrock 上游请求
func
(
s
*
GatewayService
)
buildUpstreamRequestBedrock
(
ctx
context
.
Context
,
body
[]
byte
,
modelID
string
,
region
string
,
stream
bool
,
signer
*
BedrockSigner
,
)
(
*
http
.
Request
,
error
)
{
targetURL
:=
BuildBedrockURL
(
region
,
modelID
,
stream
)
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
http
.
MethodPost
,
targetURL
,
bytes
.
NewReader
(
body
))
if
err
!=
nil
{
return
nil
,
err
}
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
Header
.
Set
(
"Accept"
,
"application/json"
)
// SigV4 签名
if
err
:=
signer
.
SignRequest
(
ctx
,
req
,
body
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"sign bedrock request: %w"
,
err
)
}
return
req
,
nil
}
// buildUpstreamRequestBedrockAPIKey 构建 Bedrock API Key (Bearer Token) 上游请求
func
(
s
*
GatewayService
)
buildUpstreamRequestBedrockAPIKey
(
ctx
context
.
Context
,
body
[]
byte
,
modelID
string
,
region
string
,
stream
bool
,
apiKey
string
,
)
(
*
http
.
Request
,
error
)
{
targetURL
:=
BuildBedrockURL
(
region
,
modelID
,
stream
)
req
,
err
:=
http
.
NewRequestWithContext
(
ctx
,
http
.
MethodPost
,
targetURL
,
bytes
.
NewReader
(
body
))
if
err
!=
nil
{
return
nil
,
err
}
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
Header
.
Set
(
"Accept"
,
"application/json"
)
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
apiKey
)
return
req
,
nil
}
// handleBedrockNonStreamingResponse 处理 Bedrock 非流式响应
// Bedrock InvokeModel 非流式响应的 body 格式与 Claude API 兼容
func
(
s
*
GatewayService
)
handleBedrockNonStreamingResponse
(
ctx
context
.
Context
,
resp
*
http
.
Response
,
c
*
gin
.
Context
,
account
*
Account
,
)
(
*
ClaudeUsage
,
error
)
{
maxBytes
:=
resolveUpstreamResponseReadLimit
(
s
.
cfg
)
body
,
err
:=
readUpstreamResponseBodyLimited
(
resp
.
Body
,
maxBytes
)
if
err
!=
nil
{
if
errors
.
Is
(
err
,
ErrUpstreamResponseBodyTooLarge
)
{
setOpsUpstreamError
(
c
,
http
.
StatusBadGateway
,
"upstream response too large"
,
""
)
c
.
JSON
(
http
.
StatusBadGateway
,
gin
.
H
{
"type"
:
"error"
,
"error"
:
gin
.
H
{
"type"
:
"upstream_error"
,
"message"
:
"Upstream response too large"
,
},
})
}
return
nil
,
err
}
// 转换 Bedrock 特有的 amazon-bedrock-invocationMetrics 为标准 Anthropic usage 格式
// 并移除该字段避免透传给客户端
body
=
transformBedrockInvocationMetrics
(
body
)
usage
:=
parseClaudeUsageFromResponseBody
(
body
)
c
.
Header
(
"Content-Type"
,
"application/json"
)
if
v
:=
resp
.
Header
.
Get
(
"x-amzn-requestid"
);
v
!=
""
{
c
.
Header
(
"x-request-id"
,
v
)
}
c
.
Data
(
resp
.
StatusCode
,
"application/json"
,
body
)
return
usage
,
nil
}
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
)
{
// 确定目标URL
targetURL
:=
claudeAPIURL
...
...
@@ -5536,6 +5908,76 @@ func containsBetaToken(header, token string) bool {
return
false
}
func
filterBetaTokens
(
tokens
[]
string
,
filterSet
map
[
string
]
struct
{})
[]
string
{
if
len
(
tokens
)
==
0
||
len
(
filterSet
)
==
0
{
return
tokens
}
kept
:=
make
([]
string
,
0
,
len
(
tokens
))
for
_
,
token
:=
range
tokens
{
if
_
,
filtered
:=
filterSet
[
token
];
!
filtered
{
kept
=
append
(
kept
,
token
)
}
}
return
kept
}
func
(
s
*
GatewayService
)
resolveBedrockBetaTokensForRequest
(
ctx
context
.
Context
,
account
*
Account
,
betaHeader
string
,
body
[]
byte
,
modelID
string
,
)
([]
string
,
error
)
{
// 1. 对原始 header 中的 beta token 做 block 检查(快速失败)
policy
:=
s
.
evaluateBetaPolicy
(
ctx
,
betaHeader
,
account
)
if
policy
.
blockErr
!=
nil
{
return
nil
,
policy
.
blockErr
}
// 2. 解析 header + body 自动注入 + Bedrock 转换/过滤
betaTokens
:=
ResolveBedrockBetaTokens
(
betaHeader
,
body
,
modelID
)
// 3. 对最终 token 列表再做 block 检查,捕获通过 body 自动注入绕过 header block 的情况。
// 例如:管理员 block 了 interleaved-thinking,客户端不在 header 中带该 token,
// 但请求体中包含 thinking 字段 → autoInjectBedrockBetaTokens 会自动补齐 →
// 如果不做此检查,block 规则会被绕过。
if
blockErr
:=
s
.
checkBetaPolicyBlockForTokens
(
ctx
,
betaTokens
,
account
);
blockErr
!=
nil
{
return
nil
,
blockErr
}
return
filterBetaTokens
(
betaTokens
,
policy
.
filterSet
),
nil
}
// checkBetaPolicyBlockForTokens 检查 token 列表中是否有被管理员 block 规则命中的 token。
// 用于补充 evaluateBetaPolicy 对 header 的检查,覆盖 body 自动注入的 token。
func
(
s
*
GatewayService
)
checkBetaPolicyBlockForTokens
(
ctx
context
.
Context
,
tokens
[]
string
,
account
*
Account
)
*
BetaBlockedError
{
if
s
.
settingService
==
nil
||
len
(
tokens
)
==
0
{
return
nil
}
settings
,
err
:=
s
.
settingService
.
GetBetaPolicySettings
(
ctx
)
if
err
!=
nil
||
settings
==
nil
{
return
nil
}
isOAuth
:=
account
.
IsOAuth
()
tokenSet
:=
buildBetaTokenSet
(
tokens
)
for
_
,
rule
:=
range
settings
.
Rules
{
if
rule
.
Action
!=
BetaPolicyActionBlock
{
continue
}
if
!
betaPolicyScopeMatches
(
rule
.
Scope
,
isOAuth
)
{
continue
}
if
_
,
present
:=
tokenSet
[
rule
.
BetaToken
];
present
{
msg
:=
rule
.
ErrorMessage
if
msg
==
""
{
msg
=
"beta feature "
+
rule
.
BetaToken
+
" is not allowed"
}
return
&
BetaBlockedError
{
Message
:
msg
}
}
}
return
nil
}
func
buildBetaTokenSet
(
tokens
[]
string
)
map
[
string
]
struct
{}
{
m
:=
make
(
map
[
string
]
struct
{},
len
(
tokens
))
for
_
,
t
:=
range
tokens
{
...
...
@@ -7321,6 +7763,12 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
return
s
.
forwardCountTokensAnthropicAPIKeyPassthrough
(
ctx
,
c
,
account
,
passthroughBody
)
}
// Bedrock 不支持 count_tokens 端点
if
account
!=
nil
&&
account
.
IsBedrock
()
{
s
.
countTokensError
(
c
,
http
.
StatusNotFound
,
"not_found_error"
,
"count_tokens endpoint is not supported for Bedrock"
)
return
nil
}
body
:=
parsed
.
Body
reqModel
:=
parsed
.
Model
...
...
backend/internal/service/gateway_service_bedrock_beta_test.go
0 → 100644
View file @
e6d59216
package
service
import
(
"context"
"encoding/json"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
)
type
betaPolicySettingRepoStub
struct
{
values
map
[
string
]
string
}
func
(
s
*
betaPolicySettingRepoStub
)
Get
(
ctx
context
.
Context
,
key
string
)
(
*
Setting
,
error
)
{
panic
(
"unexpected Get call"
)
}
func
(
s
*
betaPolicySettingRepoStub
)
GetValue
(
ctx
context
.
Context
,
key
string
)
(
string
,
error
)
{
if
v
,
ok
:=
s
.
values
[
key
];
ok
{
return
v
,
nil
}
return
""
,
ErrSettingNotFound
}
func
(
s
*
betaPolicySettingRepoStub
)
Set
(
ctx
context
.
Context
,
key
,
value
string
)
error
{
panic
(
"unexpected Set call"
)
}
func
(
s
*
betaPolicySettingRepoStub
)
GetMultiple
(
ctx
context
.
Context
,
keys
[]
string
)
(
map
[
string
]
string
,
error
)
{
panic
(
"unexpected GetMultiple call"
)
}
func
(
s
*
betaPolicySettingRepoStub
)
SetMultiple
(
ctx
context
.
Context
,
settings
map
[
string
]
string
)
error
{
panic
(
"unexpected SetMultiple call"
)
}
func
(
s
*
betaPolicySettingRepoStub
)
GetAll
(
ctx
context
.
Context
)
(
map
[
string
]
string
,
error
)
{
panic
(
"unexpected GetAll call"
)
}
func
(
s
*
betaPolicySettingRepoStub
)
Delete
(
ctx
context
.
Context
,
key
string
)
error
{
panic
(
"unexpected Delete call"
)
}
func
TestResolveBedrockBetaTokensForRequest_BlocksOnOriginalAnthropicToken
(
t
*
testing
.
T
)
{
settings
:=
&
BetaPolicySettings
{
Rules
:
[]
BetaPolicyRule
{
{
BetaToken
:
"advanced-tool-use-2025-11-20"
,
Action
:
BetaPolicyActionBlock
,
Scope
:
BetaPolicyScopeAll
,
ErrorMessage
:
"advanced tool use is blocked"
,
},
},
}
raw
,
err
:=
json
.
Marshal
(
settings
)
if
err
!=
nil
{
t
.
Fatalf
(
"marshal settings: %v"
,
err
)
}
svc
:=
&
GatewayService
{
settingService
:
NewSettingService
(
&
betaPolicySettingRepoStub
{
values
:
map
[
string
]
string
{
SettingKeyBetaPolicySettings
:
string
(
raw
),
}},
&
config
.
Config
{},
),
}
account
:=
&
Account
{
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeBedrock
}
_
,
err
=
svc
.
resolveBedrockBetaTokensForRequest
(
context
.
Background
(),
account
,
"advanced-tool-use-2025-11-20"
,
[]
byte
(
`{"messages":[{"role":"user","content":"hi"}]}`
),
"us.anthropic.claude-opus-4-6-v1"
,
)
if
err
==
nil
{
t
.
Fatal
(
"expected raw advanced-tool-use token to be blocked before Bedrock transform"
)
}
if
err
.
Error
()
!=
"advanced tool use is blocked"
{
t
.
Fatalf
(
"unexpected error: %v"
,
err
)
}
}
func
TestResolveBedrockBetaTokensForRequest_FiltersAfterBedrockTransform
(
t
*
testing
.
T
)
{
settings
:=
&
BetaPolicySettings
{
Rules
:
[]
BetaPolicyRule
{
{
BetaToken
:
"tool-search-tool-2025-10-19"
,
Action
:
BetaPolicyActionFilter
,
Scope
:
BetaPolicyScopeAll
,
},
},
}
raw
,
err
:=
json
.
Marshal
(
settings
)
if
err
!=
nil
{
t
.
Fatalf
(
"marshal settings: %v"
,
err
)
}
svc
:=
&
GatewayService
{
settingService
:
NewSettingService
(
&
betaPolicySettingRepoStub
{
values
:
map
[
string
]
string
{
SettingKeyBetaPolicySettings
:
string
(
raw
),
}},
&
config
.
Config
{},
),
}
account
:=
&
Account
{
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeBedrock
}
betaTokens
,
err
:=
svc
.
resolveBedrockBetaTokensForRequest
(
context
.
Background
(),
account
,
"advanced-tool-use-2025-11-20"
,
[]
byte
(
`{"messages":[{"role":"user","content":"hi"}]}`
),
"us.anthropic.claude-opus-4-6-v1"
,
)
if
err
!=
nil
{
t
.
Fatalf
(
"unexpected error: %v"
,
err
)
}
for
_
,
token
:=
range
betaTokens
{
if
token
==
"tool-search-tool-2025-10-19"
{
t
.
Fatalf
(
"expected transformed Bedrock token to be filtered"
)
}
}
}
// TestResolveBedrockBetaTokensForRequest_BlocksBodyAutoInjectedThinking 验证:
// 管理员 block 了 interleaved-thinking,客户端不在 header 中带该 token,
// 但请求体包含 thinking 字段 → 自动注入后应被 block。
func
TestResolveBedrockBetaTokensForRequest_BlocksBodyAutoInjectedThinking
(
t
*
testing
.
T
)
{
settings
:=
&
BetaPolicySettings
{
Rules
:
[]
BetaPolicyRule
{
{
BetaToken
:
"interleaved-thinking-2025-05-14"
,
Action
:
BetaPolicyActionBlock
,
Scope
:
BetaPolicyScopeAll
,
ErrorMessage
:
"thinking is blocked"
,
},
},
}
raw
,
err
:=
json
.
Marshal
(
settings
)
if
err
!=
nil
{
t
.
Fatalf
(
"marshal settings: %v"
,
err
)
}
svc
:=
&
GatewayService
{
settingService
:
NewSettingService
(
&
betaPolicySettingRepoStub
{
values
:
map
[
string
]
string
{
SettingKeyBetaPolicySettings
:
string
(
raw
),
}},
&
config
.
Config
{},
),
}
account
:=
&
Account
{
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeBedrock
}
// header 中不带 beta token,但 body 中有 thinking 字段
_
,
err
=
svc
.
resolveBedrockBetaTokensForRequest
(
context
.
Background
(),
account
,
""
,
// 空 header
[]
byte
(
`{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[{"role":"user","content":"hi"}]}`
),
"us.anthropic.claude-opus-4-6-v1"
,
)
if
err
==
nil
{
t
.
Fatal
(
"expected body-injected interleaved-thinking to be blocked"
)
}
if
err
.
Error
()
!=
"thinking is blocked"
{
t
.
Fatalf
(
"unexpected error: %v"
,
err
)
}
}
// TestResolveBedrockBetaTokensForRequest_BlocksBodyAutoInjectedToolSearch 验证:
// 管理员 block 了 tool-search-tool,客户端不在 header 中带 beta token,
// 但请求体包含 tool search 工具 → 自动注入后应被 block。
func
TestResolveBedrockBetaTokensForRequest_BlocksBodyAutoInjectedToolSearch
(
t
*
testing
.
T
)
{
settings
:=
&
BetaPolicySettings
{
Rules
:
[]
BetaPolicyRule
{
{
BetaToken
:
"tool-search-tool-2025-10-19"
,
Action
:
BetaPolicyActionBlock
,
Scope
:
BetaPolicyScopeAll
,
ErrorMessage
:
"tool search is blocked"
,
},
},
}
raw
,
err
:=
json
.
Marshal
(
settings
)
if
err
!=
nil
{
t
.
Fatalf
(
"marshal settings: %v"
,
err
)
}
svc
:=
&
GatewayService
{
settingService
:
NewSettingService
(
&
betaPolicySettingRepoStub
{
values
:
map
[
string
]
string
{
SettingKeyBetaPolicySettings
:
string
(
raw
),
}},
&
config
.
Config
{},
),
}
account
:=
&
Account
{
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeBedrock
}
// header 中不带 beta token,但 body 中有 tool_search_tool 工具
_
,
err
=
svc
.
resolveBedrockBetaTokensForRequest
(
context
.
Background
(),
account
,
""
,
[]
byte
(
`{"tools":[{"type":"tool_search_tool_regex_20251119","name":"search"}],"messages":[{"role":"user","content":"hi"}]}`
),
"us.anthropic.claude-sonnet-4-6"
,
)
if
err
==
nil
{
t
.
Fatal
(
"expected body-injected tool-search-tool to be blocked"
)
}
if
err
.
Error
()
!=
"tool search is blocked"
{
t
.
Fatalf
(
"unexpected error: %v"
,
err
)
}
}
// TestResolveBedrockBetaTokensForRequest_PassesWhenNoBlockRuleMatches 验证:
// body 自动注入的 token 如果没有对应的 block 规则,应正常通过。
func
TestResolveBedrockBetaTokensForRequest_PassesWhenNoBlockRuleMatches
(
t
*
testing
.
T
)
{
settings
:=
&
BetaPolicySettings
{
Rules
:
[]
BetaPolicyRule
{
{
BetaToken
:
"computer-use-2025-11-24"
,
Action
:
BetaPolicyActionBlock
,
Scope
:
BetaPolicyScopeAll
,
ErrorMessage
:
"computer use is blocked"
,
},
},
}
raw
,
err
:=
json
.
Marshal
(
settings
)
if
err
!=
nil
{
t
.
Fatalf
(
"marshal settings: %v"
,
err
)
}
svc
:=
&
GatewayService
{
settingService
:
NewSettingService
(
&
betaPolicySettingRepoStub
{
values
:
map
[
string
]
string
{
SettingKeyBetaPolicySettings
:
string
(
raw
),
}},
&
config
.
Config
{},
),
}
account
:=
&
Account
{
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeBedrock
}
// body 中有 thinking(会注入 interleaved-thinking),但 block 规则只针对 computer-use
tokens
,
err
:=
svc
.
resolveBedrockBetaTokensForRequest
(
context
.
Background
(),
account
,
""
,
[]
byte
(
`{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[{"role":"user","content":"hi"}]}`
),
"us.anthropic.claude-opus-4-6-v1"
,
)
if
err
!=
nil
{
t
.
Fatalf
(
"unexpected error: %v"
,
err
)
}
found
:=
false
for
_
,
token
:=
range
tokens
{
if
token
==
"interleaved-thinking-2025-05-14"
{
found
=
true
}
}
if
!
found
{
t
.
Fatal
(
"expected interleaved-thinking token to be present"
)
}
}
backend/internal/service/gateway_service_bedrock_model_support_test.go
0 → 100644
View file @
e6d59216
package
service
import
"testing"
func
TestGatewayServiceIsModelSupportedByAccount_BedrockDefaultMappingRestrictsModels
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
account
:=
&
Account
{
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeBedrock
,
Credentials
:
map
[
string
]
any
{
"aws_region"
:
"us-east-1"
,
},
}
if
!
svc
.
isModelSupportedByAccount
(
account
,
"claude-sonnet-4-5"
)
{
t
.
Fatalf
(
"expected default Bedrock alias to be supported"
)
}
if
svc
.
isModelSupportedByAccount
(
account
,
"claude-3-5-sonnet-20241022"
)
{
t
.
Fatalf
(
"expected unsupported alias to be rejected for Bedrock account"
)
}
}
func
TestGatewayServiceIsModelSupportedByAccount_BedrockCustomMappingStillActsAsAllowlist
(
t
*
testing
.
T
)
{
svc
:=
&
GatewayService
{}
account
:=
&
Account
{
Platform
:
PlatformAnthropic
,
Type
:
AccountTypeBedrock
,
Credentials
:
map
[
string
]
any
{
"aws_region"
:
"eu-west-1"
,
"model_mapping"
:
map
[
string
]
any
{
"claude-sonnet-*"
:
"claude-sonnet-4-6"
,
},
},
}
if
!
svc
.
isModelSupportedByAccount
(
account
,
"claude-sonnet-4-6"
)
{
t
.
Fatalf
(
"expected matched custom mapping to be supported"
)
}
if
!
svc
.
isModelSupportedByAccount
(
account
,
"claude-opus-4-6"
)
{
t
.
Fatalf
(
"expected default Bedrock alias fallback to remain supported"
)
}
if
svc
.
isModelSupportedByAccount
(
account
,
"claude-3-5-sonnet-20241022"
)
{
t
.
Fatalf
(
"expected unsupported model to still be rejected"
)
}
}
frontend/src/components/account/CreateAccountModal.vue
View file @
e6d59216
...
...
@@ -232,7 +232,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-
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'"
...
...
@@ -292,6 +292,66 @@
}}
</span>
</div>
</button>
<button
type=
"button"
@
click=
"accountCategory = 'bedrock'"
:class=
"[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'bedrock'
? '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'
? 'bg-amber-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"
>
{{
t
(
'
admin.accounts.bedrockLabel
'
)
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.bedrockDesc
'
)
}}
</span>
</div>
</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>
...
...
@@ -896,7 +956,7 @@
</div>
<!-- API Key input (only for apikey type, excluding Antigravity which has its own fields) -->
<div
v-if=
"form.type === 'apikey' && form.platform !== 'antigravity'"
class=
"space-y-4"
>
<div
v-if=
"form.type === 'apikey' && form.platform !== 'antigravity'
&& accountCategory !== 'bedrock-apikey'
"
class=
"space-y-4"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.baseUrl
'
)
}}
</label>
<input
...
...
@@ -1279,6 +1339,289 @@
<
/div
>
<!--
Bedrock
credentials
(
only
for
Anthropic
Bedrock
type
)
-->
<
div
v
-
if
=
"
form.platform === 'anthropic' && accountCategory === 'bedrock'
"
class
=
"
space-y-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockAccessKeyId
'
)
}}
<
/label
>
<
input
v
-
model
=
"
bedrockAccessKeyId
"
type
=
"
text
"
required
class
=
"
input font-mono
"
placeholder
=
"
AKIA...
"
/>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockSecretAccessKey
'
)
}}
<
/label
>
<
input
v
-
model
=
"
bedrockSecretAccessKey
"
type
=
"
password
"
required
class
=
"
input font-mono
"
/>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockSessionToken
'
)
}}
<
/label
>
<
input
v
-
model
=
"
bedrockSessionToken
"
type
=
"
password
"
class
=
"
input font-mono
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.bedrockSessionTokenHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockRegion
'
)
}}
<
/label
>
<
select
v
-
model
=
"
bedrockRegion
"
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
>
<
label
class
=
"
flex items-center gap-2 cursor-pointer
"
>
<
input
v
-
model
=
"
bedrockForceGlobal
"
type
=
"
checkbox
"
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
>
<!--
Model
Restriction
Section
for
Bedrock
-->
<
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
type
=
"
button
"
@
click
=
"
modelRestrictionMode = 'whitelist'
"
:
class
=
"
[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? '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
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
modelRestrictionMode = 'mapping'
"
:
class
=
"
[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping'
? '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
>
<
/div
>
<!--
Whitelist
Mode
-->
<
div
v
-
if
=
"
modelRestrictionMode === 'whitelist'
"
>
<
ModelWhitelistSelector
v
-
model
=
"
allowedModels
"
platform
=
"
anthropic
"
/>
<
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
>
<
/div
>
<!--
Mapping
Mode
-->
<
div
v
-
else
class
=
"
space-y-3
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
class
=
"
flex items-center gap-2
"
>
<
input
v
-
model
=
"
mapping.from
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.fromModel')
"
/>
<
span
class
=
"
text-gray-400
"
>
→
<
/span
>
<
input
v
-
model
=
"
mapping.to
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.toModel')
"
/>
<
button
type
=
"
button
"
@
click
=
"
modelMappings.splice(index, 1)
"
class
=
"
text-red-500 hover:text-red-700
"
>
<
Icon
name
=
"
trash
"
size
=
"
sm
"
/>
<
/button
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
modelMappings.push({ from: '', to: ''
}
)
"
class
=
"
btn btn-secondary text-sm
"
>
+
{{
t
(
'
admin.accounts.addMapping
'
)
}}
<
/button
>
<!--
Bedrock
Preset
Mappings
-->
<
div
class
=
"
flex flex-wrap gap-2
"
>
<
button
v
-
for
=
"
preset in bedrockPresets
"
:
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
>
<!--
Bedrock
API
Key
credentials
(
only
for
Anthropic
Bedrock
API
Key
type
)
-->
<
div
v
-
if
=
"
form.platform === 'anthropic' && accountCategory === 'bedrock-apikey'
"
class
=
"
space-y-4
"
>
<
div
>
<
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
>
<
label
class
=
"
flex items-center gap-2 cursor-pointer
"
>
<
input
v
-
model
=
"
bedrockApiKeyForceGlobal
"
type
=
"
checkbox
"
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
>
<!--
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
type
=
"
button
"
@
click
=
"
modelRestrictionMode = 'whitelist'
"
:
class
=
"
[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? '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
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
modelRestrictionMode = 'mapping'
"
:
class
=
"
[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping'
? '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
>
<
/div
>
<!--
Whitelist
Mode
-->
<
div
v
-
if
=
"
modelRestrictionMode === 'whitelist'
"
>
<
ModelWhitelistSelector
v
-
model
=
"
allowedModels
"
platform
=
"
anthropic
"
/>
<
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
>
<
/div
>
<!--
Mapping
Mode
-->
<
div
v
-
else
class
=
"
space-y-3
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
class
=
"
flex items-center gap-2
"
>
<
input
v
-
model
=
"
mapping.from
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.fromModel')
"
/>
<
span
class
=
"
text-gray-400
"
>
→
<
/span
>
<
input
v
-
model
=
"
mapping.to
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.toModel')
"
/>
<
button
type
=
"
button
"
@
click
=
"
modelMappings.splice(index, 1)
"
class
=
"
text-red-500 hover:text-red-700
"
>
<
Icon
name
=
"
trash
"
size
=
"
sm
"
/>
<
/button
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
modelMappings.push({ from: '', to: ''
}
)
"
class
=
"
btn btn-secondary text-sm
"
>
+
{{
t
(
'
admin.accounts.addMapping
'
)
}}
<
/button
>
<!--
Bedrock
Preset
Mappings
-->
<
div
class
=
"
flex flex-wrap gap-2
"
>
<
button
v
-
for
=
"
preset in bedrockPresets
"
:
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
>
<!--
API
Key
账号配额限制
-->
<
div
v
-
if
=
"
form.type === 'apikey'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
>
<
div
class
=
"
mb-3
"
>
...
...
@@ -2671,7 +3014,7 @@ interface TempUnschedRuleForm {
// State
const
step
=
ref
(
1
)
const
submitting
=
ref
(
false
)
const
accountCategory
=
ref
<
'
oauth-based
'
|
'
apikey
'
>
(
'
oauth-based
'
)
// UI selection for account category
const
accountCategory
=
ref
<
'
oauth-based
'
|
'
apikey
'
|
'
bedrock
'
|
'
bedrock-apikey
'
>
(
'
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
(
''
)
...
...
@@ -2704,6 +3047,19 @@ const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist'
const
antigravityWhitelistModels
=
ref
<
string
[]
>
([])
const
antigravityModelMappings
=
ref
<
ModelMapping
[]
>
([])
const
antigravityPresetMappings
=
computed
(()
=>
getPresetMappingsByPlatform
(
'
antigravity
'
))
const
bedrockPresets
=
computed
(()
=>
getPresetMappingsByPlatform
(
'
bedrock
'
))
// Bedrock credentials
const
bedrockAccessKeyId
=
ref
(
''
)
const
bedrockSecretAccessKey
=
ref
(
''
)
const
bedrockSessionToken
=
ref
(
''
)
const
bedrockRegion
=
ref
(
'
us-east-1
'
)
const
bedrockForceGlobal
=
ref
(
false
)
// Bedrock API Key credentials
const
bedrockApiKeyValue
=
ref
(
''
)
const
bedrockApiKeyRegion
=
ref
(
'
us-east-1
'
)
const
bedrockApiKeyForceGlobal
=
ref
(
false
)
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
const
getModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
create-model-mapping
'
)
...
...
@@ -2868,6 +3224,10 @@ const isOAuthFlow = computed(() => {
if
(
form
.
platform
===
'
antigravity
'
&&
antigravityAccountType
.
value
===
'
upstream
'
)
{
return
false
}
// Bedrock 类型不需要 OAuth 流程
if
(
form
.
platform
===
'
anthropic
'
&&
accountCategory
.
value
===
'
bedrock
'
)
{
return
false
}
return
accountCategory
.
value
===
'
oauth-based
'
}
)
...
...
@@ -2935,6 +3295,11 @@ watch(
form
.
type
=
'
apikey
'
return
}
// Bedrock 类型
if
(
form
.
platform
===
'
anthropic
'
&&
category
===
'
bedrock
'
)
{
form
.
type
=
'
bedrock
'
as
AccountType
return
}
if
(
category
===
'
oauth-based
'
)
{
form
.
type
=
method
as
AccountType
// 'oauth' or 'setup-token'
}
else
{
...
...
@@ -2972,6 +3337,13 @@ watch(
antigravityModelMappings
.
value
=
[]
antigravityModelRestrictionMode
.
value
=
'
mapping
'
}
// Reset Bedrock fields when switching platforms
bedrockAccessKeyId
.
value
=
''
bedrockSecretAccessKey
.
value
=
''
bedrockSessionToken
.
value
=
''
bedrockRegion
.
value
=
'
us-east-1
'
bedrockForceGlobal
.
value
=
false
bedrockApiKeyForceGlobal
.
value
=
false
// Reset Anthropic/Antigravity-specific settings when switching to other platforms
if
(
newPlatform
!==
'
anthropic
'
&&
newPlatform
!==
'
antigravity
'
)
{
interceptWarmupRequests
.
value
=
false
...
...
@@ -3541,6 +3913,84 @@ const handleSubmit = async () => {
return
}
// For Bedrock type, create directly
if
(
form
.
platform
===
'
anthropic
'
&&
accountCategory
.
value
===
'
bedrock
'
)
{
if
(
!
form
.
name
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.pleaseEnterAccountName
'
))
return
}
if
(
!
bedrockAccessKeyId
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.bedrockAccessKeyIdRequired
'
))
return
}
if
(
!
bedrockSecretAccessKey
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.bedrockSecretAccessKeyRequired
'
))
return
}
if
(
!
bedrockRegion
.
value
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.bedrockRegionRequired
'
))
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
())
{
credentials
.
aws_session_token
=
bedrockSessionToken
.
value
.
trim
()
}
if
(
bedrockForceGlobal
.
value
)
{
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
())
{
appStore
.
showError
(
t
(
'
admin.accounts.bedrockApiKeyRequired
'
))
return
}
const
credentials
:
Record
<
string
,
unknown
>
=
{
api_key
:
bedrockApiKeyValue
.
value
.
trim
(),
aws_region
:
bedrockApiKeyRegion
.
value
.
trim
()
||
'
us-east-1
'
,
}
if
(
bedrockApiKeyForceGlobal
.
value
)
{
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-apikey
'
as
AccountType
,
credentials
)
return
}
// For Antigravity upstream type, create directly
if
(
form
.
platform
===
'
antigravity
'
&&
antigravityAccountType
.
value
===
'
upstream
'
)
{
if
(
!
form
.
name
.
trim
())
{
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
e6d59216
...
...
@@ -563,6 +563,233 @@
<
/div
>
<
/div
>
<!--
Bedrock
fields
(
only
for
bedrock
type
)
-->
<
div
v
-
if
=
"
account.type === 'bedrock'
"
class
=
"
space-y-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockAccessKeyId
'
)
}}
<
/label
>
<
input
v
-
model
=
"
editBedrockAccessKeyId
"
type
=
"
text
"
class
=
"
input font-mono
"
placeholder
=
"
AKIA...
"
/>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockSecretAccessKey
'
)
}}
<
/label
>
<
input
v
-
model
=
"
editBedrockSecretAccessKey
"
type
=
"
password
"
class
=
"
input font-mono
"
:
placeholder
=
"
t('admin.accounts.bedrockSecretKeyLeaveEmpty')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.bedrockSecretKeyLeaveEmpty
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockSessionToken
'
)
}}
<
/label
>
<
input
v
-
model
=
"
editBedrockSessionToken
"
type
=
"
password
"
class
=
"
input font-mono
"
:
placeholder
=
"
t('admin.accounts.bedrockSecretKeyLeaveEmpty')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.bedrockSessionTokenHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.bedrockRegion
'
)
}}
<
/label
>
<
input
v
-
model
=
"
editBedrockRegion
"
type
=
"
text
"
class
=
"
input
"
placeholder
=
"
us-east-1
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.bedrockRegionHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
flex items-center gap-2 cursor-pointer
"
>
<
input
v
-
model
=
"
editBedrockForceGlobal
"
type
=
"
checkbox
"
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
>
<!--
Model
Restriction
for
Bedrock
-->
<
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
type
=
"
button
"
@
click
=
"
modelRestrictionMode = 'whitelist'
"
:
class
=
"
[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? '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
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
modelRestrictionMode = 'mapping'
"
:
class
=
"
[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping'
? '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
>
<
/div
>
<!--
Whitelist
Mode
-->
<
div
v
-
if
=
"
modelRestrictionMode === 'whitelist'
"
>
<
ModelWhitelistSelector
v
-
model
=
"
allowedModels
"
platform
=
"
anthropic
"
/>
<
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
>
<
/div
>
<!--
Mapping
Mode
-->
<
div
v
-
else
class
=
"
space-y-3
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
getModelMappingKey(mapping)
"
class
=
"
flex items-center gap-2
"
>
<
input
v
-
model
=
"
mapping.from
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.fromModel')
"
/>
<
span
class
=
"
text-gray-400
"
>
→
<
/span
>
<
input
v
-
model
=
"
mapping.to
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.toModel')
"
/>
<
button
type
=
"
button
"
@
click
=
"
modelMappings.splice(index, 1)
"
class
=
"
text-red-500 hover:text-red-700
"
>
<
Icon
name
=
"
trash
"
size
=
"
sm
"
/>
<
/button
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
modelMappings.push({ from: '', to: ''
}
)
"
class
=
"
btn btn-secondary text-sm
"
>
+
{{
t
(
'
admin.accounts.addMapping
'
)
}}
<
/button
>
<!--
Bedrock
Preset
Mappings
-->
<
div
class
=
"
flex flex-wrap gap-2
"
>
<
button
v
-
for
=
"
preset in bedrockPresets
"
:
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
>
<!--
Bedrock
API
Key
fields
(
only
for
bedrock
-
apikey
type
)
-->
<
div
v
-
if
=
"
account.type === 'bedrock-apikey'
"
class
=
"
space-y-4
"
>
<
div
>
<
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
>
<
label
class
=
"
flex items-center gap-2 cursor-pointer
"
>
<
input
v
-
model
=
"
editBedrockApiKeyForceGlobal
"
type
=
"
checkbox
"
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
>
<!--
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
type
=
"
button
"
@
click
=
"
modelRestrictionMode = 'whitelist'
"
:
class
=
"
[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? '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
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
modelRestrictionMode = 'mapping'
"
:
class
=
"
[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping'
? '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
>
<
/div
>
<!--
Whitelist
Mode
-->
<
div
v
-
if
=
"
modelRestrictionMode === 'whitelist'
"
>
<
ModelWhitelistSelector
v
-
model
=
"
allowedModels
"
platform
=
"
anthropic
"
/>
<
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
>
<
/div
>
<!--
Mapping
Mode
-->
<
div
v
-
else
class
=
"
space-y-3
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
getModelMappingKey(mapping)
"
class
=
"
flex items-center gap-2
"
>
<
input
v
-
model
=
"
mapping.from
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.fromModel')
"
/>
<
span
class
=
"
text-gray-400
"
>
→
<
/span
>
<
input
v
-
model
=
"
mapping.to
"
type
=
"
text
"
class
=
"
input flex-1
"
:
placeholder
=
"
t('admin.accounts.toModel')
"
/>
<
button
type
=
"
button
"
@
click
=
"
modelMappings.splice(index, 1)
"
class
=
"
text-red-500 hover:text-red-700
"
>
<
Icon
name
=
"
trash
"
size
=
"
sm
"
/>
<
/button
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
modelMappings.push({ from: '', to: ''
}
)
"
class
=
"
btn btn-secondary text-sm
"
>
+
{{
t
(
'
admin.accounts.addMapping
'
)
}}
<
/button
>
<!--
Bedrock
Preset
Mappings
-->
<
div
class
=
"
flex flex-wrap gap-2
"
>
<
button
v
-
for
=
"
preset in bedrockPresets
"
:
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
>
<!--
Antigravity
model
restriction
(
applies
to
all
antigravity
types
)
-->
<!--
Antigravity
只支持模型映射模式
,
不支持白名单模式
-->
<
div
v
-
if
=
"
account.platform === 'antigravity'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
...
...
@@ -1529,6 +1756,7 @@ const baseUrlHint = computed(() => {
}
)
const
antigravityPresetMappings
=
computed
(()
=>
getPresetMappingsByPlatform
(
'
antigravity
'
))
const
bedrockPresets
=
computed
(()
=>
getPresetMappingsByPlatform
(
'
bedrock
'
))
// Model mapping type
interface
ModelMapping
{
...
...
@@ -1547,6 +1775,17 @@ interface TempUnschedRuleForm {
const
submitting
=
ref
(
false
)
const
editBaseUrl
=
ref
(
'
https://api.anthropic.com
'
)
const
editApiKey
=
ref
(
''
)
// Bedrock credentials
const
editBedrockAccessKeyId
=
ref
(
''
)
const
editBedrockSecretAccessKey
=
ref
(
''
)
const
editBedrockSessionToken
=
ref
(
''
)
const
editBedrockRegion
=
ref
(
''
)
const
editBedrockForceGlobal
=
ref
(
false
)
// Bedrock API Key credentials
const
editBedrockApiKeyValue
=
ref
(
''
)
const
editBedrockApiKeyRegion
=
ref
(
''
)
const
editBedrockApiKeyForceGlobal
=
ref
(
false
)
const
modelMappings
=
ref
<
ModelMapping
[]
>
([])
const
modelRestrictionMode
=
ref
<
'
whitelist
'
|
'
mapping
'
>
(
'
whitelist
'
)
const
allowedModels
=
ref
<
string
[]
>
([])
...
...
@@ -1889,6 +2128,58 @@ watch(
}
else
{
selectedErrorCodes
.
value
=
[]
}
}
else
if
(
newAccount
.
type
===
'
bedrock
'
&&
newAccount
.
credentials
)
{
const
bedrockCreds
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
editBedrockAccessKeyId
.
value
=
(
bedrockCreds
.
aws_access_key_id
as
string
)
||
''
editBedrockRegion
.
value
=
(
bedrockCreds
.
aws_region
as
string
)
||
''
editBedrockForceGlobal
.
value
=
(
bedrockCreds
.
aws_force_global
as
string
)
===
'
true
'
editBedrockSecretAccessKey
.
value
=
''
editBedrockSessionToken
.
value
=
''
// Load model mappings for bedrock
const
existingMappings
=
bedrockCreds
.
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
===
'
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
)
{
const
credentials
=
newAccount
.
credentials
as
Record
<
string
,
unknown
>
editBaseUrl
.
value
=
(
credentials
.
base_url
as
string
)
||
''
...
...
@@ -2431,6 +2722,70 @@ const handleSubmit = async () => {
return
}
updatePayload
.
credentials
=
newCredentials
}
else
if
(
props
.
account
.
type
===
'
bedrock
'
)
{
const
currentCredentials
=
(
props
.
account
.
credentials
as
Record
<
string
,
unknown
>
)
||
{
}
const
newCredentials
:
Record
<
string
,
unknown
>
=
{
...
currentCredentials
}
newCredentials
.
aws_access_key_id
=
editBedrockAccessKeyId
.
value
.
trim
()
newCredentials
.
aws_region
=
editBedrockRegion
.
value
.
trim
()
if
(
editBedrockForceGlobal
.
value
)
{
newCredentials
.
aws_force_global
=
'
true
'
}
else
{
delete
newCredentials
.
aws_force_global
}
// Only update secrets if user provided new values
if
(
editBedrockSecretAccessKey
.
value
.
trim
())
{
newCredentials
.
aws_secret_access_key
=
editBedrockSecretAccessKey
.
value
.
trim
()
}
if
(
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
}
else
if
(
props
.
account
.
type
===
'
bedrock-apikey
'
)
{
const
currentCredentials
=
(
props
.
account
.
credentials
as
Record
<
string
,
unknown
>
)
||
{
}
const
newCredentials
:
Record
<
string
,
unknown
>
=
{
...
currentCredentials
}
newCredentials
.
aws_region
=
editBedrockApiKeyRegion
.
value
.
trim
()
||
'
us-east-1
'
if
(
editBedrockApiKeyForceGlobal
.
value
)
{
newCredentials
.
aws_force_global
=
'
true
'
}
else
{
delete
newCredentials
.
aws_force_global
}
// Only update API key if user provided new value
if
(
editBedrockApiKeyValue
.
value
.
trim
())
{
newCredentials
.
api_key
=
editBedrockApiKeyValue
.
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
}
else
{
// For oauth/setup-token types, only update intercept_warmup_requests if changed
...
...
frontend/src/components/common/PlatformTypeBadge.vue
View file @
e6d59216
...
...
@@ -82,6 +82,8 @@ const typeLabel = computed(() => {
return
'
Token
'
case
'
apikey
'
:
return
'
Key
'
case
'
bedrock
'
:
return
'
Bedrock
'
default
:
return
props
.
type
}
...
...
frontend/src/composables/useModelWhitelist.ts
View file @
e6d59216
...
...
@@ -331,6 +331,15 @@ const antigravityPresetMappings = [
{
label
:
'
Opus 4.6-thinking
'
,
from
:
'
claude-opus-4-6-thinking
'
,
to
:
'
claude-opus-4-6-thinking
'
,
color
:
'
bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400
'
}
]
// Bedrock 预设映射(与后端 DefaultBedrockModelMapping 保持一致)
const
bedrockPresetMappings
=
[
{
label
:
'
Opus 4.6
'
,
from
:
'
claude-opus-4-6
'
,
to
:
'
us.anthropic.claude-opus-4-6-v1
'
,
color
:
'
bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400
'
},
{
label
:
'
Sonnet 4.6
'
,
from
:
'
claude-sonnet-4-6
'
,
to
:
'
us.anthropic.claude-sonnet-4-6
'
,
color
:
'
bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400
'
},
{
label
:
'
Opus 4.5
'
,
from
:
'
claude-opus-4-5-thinking
'
,
to
:
'
us.anthropic.claude-opus-4-5-20251101-v1:0
'
,
color
:
'
bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400
'
},
{
label
:
'
Sonnet 4.5
'
,
from
:
'
claude-sonnet-4-5
'
,
to
:
'
us.anthropic.claude-sonnet-4-5-20250929-v1:0
'
,
color
:
'
bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400
'
},
{
label
:
'
Haiku 4.5
'
,
from
:
'
claude-haiku-4-5
'
,
to
:
'
us.anthropic.claude-haiku-4-5-20251001-v1:0
'
,
color
:
'
bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400
'
},
]
// Antigravity 默认映射(从后端 API 获取,与 constants.go 保持一致)
// 使用 fetchAntigravityDefaultMappings() 异步获取
import
{
getAntigravityDefaultModelMapping
}
from
'
@/api/admin/accounts
'
...
...
@@ -403,6 +412,7 @@ export function getPresetMappingsByPlatform(platform: string) {
if
(
platform
===
'
gemini
'
)
return
geminiPresetMappings
if
(
platform
===
'
sora
'
)
return
soraPresetMappings
if
(
platform
===
'
antigravity
'
)
return
antigravityPresetMappings
if
(
platform
===
'
bedrock
'
||
platform
===
'
bedrock-apikey
'
)
return
bedrockPresetMappings
return
anthropicPresetMappings
}
...
...
Prev
1
2
Next
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