bedrock_request.go 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858
  1. package service
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "net/url"
  6. "regexp"
  7. "strconv"
  8. "strings"
  9. "github.com/Wei-Shaw/sub2api/internal/domain"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  12. "github.com/tidwall/gjson"
  13. "github.com/tidwall/sjson"
  14. )
  15. const defaultBedrockRegion = "us-east-1"
  16. // featureKeyBedrockCCCompat is the key used in Channel.FeaturesConfig for Bedrock CC compatibility.
  17. const featureKeyBedrockCCCompat = "bedrock_cc_compat"
  18. var bedrockCrossRegionPrefixes = []string{"us.", "eu.", "apac.", "jp.", "au.", "us-gov.", "global."}
  19. // BedrockCrossRegionPrefix 根据 AWS Region 返回 Bedrock 跨区域推理的模型 ID 前缀
  20. // 参考: https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
  21. func BedrockCrossRegionPrefix(region string) string {
  22. switch {
  23. case strings.HasPrefix(region, "us-gov"):
  24. return "us-gov" // GovCloud 使用独立的 us-gov 前缀
  25. case strings.HasPrefix(region, "us-"):
  26. return "us"
  27. case strings.HasPrefix(region, "eu-"):
  28. return "eu"
  29. case region == "ap-northeast-1":
  30. return "jp" // 日本区域使用独立的 jp 前缀(AWS 官方定义)
  31. case region == "ap-southeast-2":
  32. return "au" // 澳大利亚区域使用独立的 au 前缀(AWS 官方定义)
  33. case strings.HasPrefix(region, "ap-"):
  34. return "apac" // 其余亚太区域使用通用 apac 前缀
  35. case strings.HasPrefix(region, "ca-"):
  36. return "us" // 加拿大区域使用 us 前缀的跨区域推理
  37. case strings.HasPrefix(region, "sa-"):
  38. return "us" // 南美区域使用 us 前缀的跨区域推理
  39. default:
  40. return "us"
  41. }
  42. }
  43. // AdjustBedrockModelRegionPrefix 将模型 ID 的区域前缀替换为与当前 AWS Region 匹配的前缀
  44. // 例如 region=eu-west-1 时,"us.anthropic.claude-opus-4-6-v1" → "eu.anthropic.claude-opus-4-6-v1"
  45. // 特殊值 region="global" 强制使用 global. 前缀
  46. func AdjustBedrockModelRegionPrefix(modelID, region string) string {
  47. var targetPrefix string
  48. if region == "global" {
  49. targetPrefix = "global"
  50. } else {
  51. targetPrefix = BedrockCrossRegionPrefix(region)
  52. }
  53. for _, p := range bedrockCrossRegionPrefixes {
  54. if strings.HasPrefix(modelID, p) {
  55. if p == targetPrefix+"." {
  56. return modelID // 前缀已匹配,无需替换
  57. }
  58. return targetPrefix + "." + modelID[len(p):]
  59. }
  60. }
  61. // 模型 ID 没有已知区域前缀(如 "anthropic.claude-..."),不做修改
  62. return modelID
  63. }
  64. func bedrockRuntimeRegion(account *Account) string {
  65. if account == nil {
  66. return defaultBedrockRegion
  67. }
  68. if region := account.GetCredential("aws_region"); region != "" {
  69. return region
  70. }
  71. return defaultBedrockRegion
  72. }
  73. func shouldForceBedrockGlobal(account *Account) bool {
  74. return account != nil && account.GetCredential("aws_force_global") == "true"
  75. }
  76. func isRegionalBedrockModelID(modelID string) bool {
  77. for _, prefix := range bedrockCrossRegionPrefixes {
  78. if strings.HasPrefix(modelID, prefix) {
  79. return true
  80. }
  81. }
  82. return false
  83. }
  84. func isLikelyBedrockModelID(modelID string) bool {
  85. lower := strings.ToLower(strings.TrimSpace(modelID))
  86. if lower == "" {
  87. return false
  88. }
  89. if strings.HasPrefix(lower, "arn:") {
  90. return true
  91. }
  92. for _, prefix := range []string{
  93. "anthropic.",
  94. "amazon.",
  95. "meta.",
  96. "mistral.",
  97. "cohere.",
  98. "ai21.",
  99. "deepseek.",
  100. "stability.",
  101. "writer.",
  102. "nova.",
  103. } {
  104. if strings.HasPrefix(lower, prefix) {
  105. return true
  106. }
  107. }
  108. return isRegionalBedrockModelID(lower)
  109. }
  110. func normalizeBedrockModelID(modelID string) (normalized string, shouldAdjustRegion bool, ok bool) {
  111. modelID = strings.TrimSpace(modelID)
  112. if modelID == "" {
  113. return "", false, false
  114. }
  115. if mapped, exists := domain.DefaultBedrockModelMapping[modelID]; exists {
  116. return mapped, true, true
  117. }
  118. if isRegionalBedrockModelID(modelID) {
  119. return modelID, true, true
  120. }
  121. if isLikelyBedrockModelID(modelID) {
  122. return modelID, false, true
  123. }
  124. return "", false, false
  125. }
  126. // ResolveBedrockModelID resolves a requested Claude model into a Bedrock model ID.
  127. // It applies account model_mapping first, then default Bedrock aliases, and finally
  128. // adjusts Anthropic cross-region prefixes to match the account region.
  129. func ResolveBedrockModelID(account *Account, requestedModel string) (string, bool) {
  130. if account == nil {
  131. return "", false
  132. }
  133. mappedModel := account.GetMappedModel(requestedModel)
  134. modelID, shouldAdjustRegion, ok := normalizeBedrockModelID(mappedModel)
  135. if !ok {
  136. return "", false
  137. }
  138. if shouldAdjustRegion {
  139. targetRegion := bedrockRuntimeRegion(account)
  140. if shouldForceBedrockGlobal(account) {
  141. targetRegion = "global"
  142. }
  143. modelID = AdjustBedrockModelRegionPrefix(modelID, targetRegion)
  144. }
  145. return modelID, true
  146. }
  147. // BuildBedrockURL 构建 Bedrock InvokeModel 的 URL
  148. // stream=true 时使用 invoke-with-response-stream 端点
  149. // modelID 中的特殊字符会被 URL 编码(与 litellm 的 urllib.parse.quote(safe="") 对齐)
  150. func BuildBedrockURL(region, modelID string, stream bool) string {
  151. if region == "" {
  152. region = defaultBedrockRegion
  153. }
  154. encodedModelID := url.PathEscape(modelID)
  155. // url.PathEscape 不编码冒号(RFC 允许 path 中出现 ":"),
  156. // 但 AWS Bedrock 期望模型 ID 中的冒号被编码为 %3A
  157. encodedModelID = strings.ReplaceAll(encodedModelID, ":", "%3A")
  158. if stream {
  159. return fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com/model/%s/invoke-with-response-stream", region, encodedModelID)
  160. }
  161. return fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com/model/%s/invoke", region, encodedModelID)
  162. }
  163. // PrepareBedrockRequestBody 处理请求体以适配 Bedrock API
  164. // 1. 注入 anthropic_version
  165. // 2. 注入 anthropic_beta(从客户端 anthropic-beta 头解析)
  166. // 3. 移除 Bedrock 不支持的字段(model, stream, output_format, output_config)
  167. // 4. 移除工具定义中的 custom 字段(Claude Code 会发送 custom: {defer_loading: true})
  168. // 5. 清理 cache_control 中 Bedrock 不支持的字段(scope, ttl)
  169. // 6. 修复 thinking 字段兼容性(Opus 4.7 仅支持 adaptive,enabled 需要 budget_tokens)
  170. // 7. 清理 tool_use.id / tool_use_id 中 Bedrock 不接受的字符
  171. // 8. 根据最终 Bedrock beta tokens 剥离不再支持的 beta 字段
  172. func PrepareBedrockRequestBody(body []byte, modelID string, betaHeader string) ([]byte, error) {
  173. betaTokens := ResolveBedrockBetaTokens(betaHeader, body, modelID)
  174. return PrepareBedrockRequestBodyWithTokens(body, modelID, betaTokens, false)
  175. }
  176. // PrepareBedrockRequestBodyWithTokens prepares a Bedrock request using pre-resolved beta tokens.
  177. // ccCompat 启用 CC 兼容模式时额外处理 thinking 类型转换和 tool_use.id 清理。
  178. func PrepareBedrockRequestBodyWithTokens(body []byte, modelID string, betaTokens []string, ccCompat bool) ([]byte, error) {
  179. var err error
  180. betaTokens = filterBedrockBetaTokens(betaTokens)
  181. body = sanitizeBedrockFieldsForBetaTokens(body, betaTokens)
  182. // 注入 anthropic_version(Bedrock 要求)
  183. body, err = sjson.SetBytes(body, "anthropic_version", "bedrock-2023-05-31")
  184. if err != nil {
  185. return nil, fmt.Errorf("inject anthropic_version: %w", err)
  186. }
  187. // 注入 anthropic_beta(Bedrock Invoke 通过请求体传递 beta 头,而非 HTTP 头)
  188. // 1. 从客户端 anthropic-beta header 解析
  189. // 2. 根据请求体内容自动补齐必要的 beta token
  190. // 参考 litellm: AnthropicModelInfo.get_anthropic_beta_list() + _get_tool_search_beta_header_for_bedrock()
  191. if len(betaTokens) > 0 {
  192. body, err = sjson.SetBytes(body, "anthropic_beta", betaTokens)
  193. if err != nil {
  194. return nil, fmt.Errorf("inject anthropic_beta: %w", err)
  195. }
  196. logger.LegacyPrintf("service.gateway", "[Bedrock] Injected beta tokens: %v (model=%s ccCompat=%v)", betaTokens, modelID, ccCompat)
  197. } else {
  198. body, _ = sjson.DeleteBytes(body, "anthropic_beta")
  199. }
  200. // 移除 Bedrock 不支持的 Anthropic 直连 API 专有顶层字段
  201. body, _ = sjson.DeleteBytes(body, "provider")
  202. body, _ = sjson.DeleteBytes(body, "metadata")
  203. // 移除 model 字段(Bedrock 通过 URL 指定模型)
  204. body, err = sjson.DeleteBytes(body, "model")
  205. if err != nil {
  206. return nil, fmt.Errorf("remove model field: %w", err)
  207. }
  208. // 移除 stream 字段(Bedrock 通过不同端点控制流式,不接受请求体中的 stream 字段)
  209. body, err = sjson.DeleteBytes(body, "stream")
  210. if err != nil {
  211. return nil, fmt.Errorf("remove stream field: %w", err)
  212. }
  213. // 转换 output_format(Bedrock Invoke 不支持此字段,但可将 schema 内联到最后一条 user message)
  214. // 参考 litellm: _convert_output_format_to_inline_schema()
  215. body = convertOutputFormatToInlineSchema(body)
  216. // 移除 output_config 字段(Bedrock Invoke 不支持)
  217. body, err = sjson.DeleteBytes(body, "output_config")
  218. if err != nil {
  219. return nil, fmt.Errorf("remove output_config field: %w", err)
  220. }
  221. // 移除工具定义中的 custom 字段
  222. // Claude Code (v2.1.69+) 在 tool 定义中发送 custom: {defer_loading: true},
  223. // Anthropic API 接受但 Bedrock 会拒绝并报 "Extra inputs are not permitted"
  224. body = removeCustomFieldFromTools(body)
  225. // 清理 cache_control 中 Bedrock 不支持的字段
  226. body = sanitizeBedrockCacheControl(body, modelID)
  227. // CC 兼容模式:修复 CC 发送的 Bedrock 不兼容字段
  228. if ccCompat {
  229. body = sanitizeBedrockThinking(body, modelID)
  230. body = sanitizeBedrockToolUseIDs(body)
  231. }
  232. return body, nil
  233. }
  234. // ResolveBedrockBetaTokens computes the final Bedrock beta token list before policy filtering.
  235. func ResolveBedrockBetaTokens(betaHeader string, body []byte, modelID string) []string {
  236. betaTokens := parseAnthropicBetaHeader(betaHeader)
  237. betaTokens = autoInjectBedrockBetaTokens(betaTokens, body, modelID)
  238. return filterBedrockBetaTokens(betaTokens)
  239. }
  240. // convertOutputFormatToInlineSchema 将 output_format 中的 JSON schema 内联到最后一条 user message
  241. // Bedrock Invoke 不支持 output_format 参数,litellm 的做法是将 schema 追加到用户消息中
  242. // 参考: litellm AmazonAnthropicClaudeMessagesConfig._convert_output_format_to_inline_schema()
  243. func convertOutputFormatToInlineSchema(body []byte) []byte {
  244. outputFormat := gjson.GetBytes(body, "output_format")
  245. if !outputFormat.Exists() || !outputFormat.IsObject() {
  246. return body
  247. }
  248. // 先从请求体中移除 output_format
  249. body, _ = sjson.DeleteBytes(body, "output_format")
  250. schema := outputFormat.Get("schema")
  251. if !schema.Exists() {
  252. return body
  253. }
  254. // 找到最后一条 user message
  255. messages := gjson.GetBytes(body, "messages")
  256. if !messages.Exists() || !messages.IsArray() {
  257. return body
  258. }
  259. msgArr := messages.Array()
  260. lastUserIdx := -1
  261. for i := len(msgArr) - 1; i >= 0; i-- {
  262. if msgArr[i].Get("role").String() == "user" {
  263. lastUserIdx = i
  264. break
  265. }
  266. }
  267. if lastUserIdx < 0 {
  268. return body
  269. }
  270. // 将 schema 序列化为 JSON 文本追加到该 message 的 content 数组
  271. schemaJSON, err := json.Marshal(json.RawMessage(schema.Raw))
  272. if err != nil {
  273. return body
  274. }
  275. content := msgArr[lastUserIdx].Get("content")
  276. basePath := fmt.Sprintf("messages.%d.content", lastUserIdx)
  277. if content.IsArray() {
  278. // 追加一个 text block 到 content 数组末尾
  279. idx := len(content.Array())
  280. body, _ = sjson.SetBytes(body, fmt.Sprintf("%s.%d.type", basePath, idx), "text")
  281. body, _ = sjson.SetBytes(body, fmt.Sprintf("%s.%d.text", basePath, idx), string(schemaJSON))
  282. } else if content.Type == gjson.String {
  283. // content 是纯字符串,转换为数组格式
  284. originalText := content.String()
  285. body, _ = sjson.SetBytes(body, basePath, []map[string]string{
  286. {"type": "text", "text": originalText},
  287. {"type": "text", "text": string(schemaJSON)},
  288. })
  289. }
  290. return body
  291. }
  292. // removeCustomFieldFromTools 移除 tools 数组中每个工具定义的 custom 字段
  293. func removeCustomFieldFromTools(body []byte) []byte {
  294. tools := gjson.GetBytes(body, "tools")
  295. if !tools.Exists() || !tools.IsArray() {
  296. return body
  297. }
  298. var err error
  299. for i := range tools.Array() {
  300. body, err = sjson.DeleteBytes(body, fmt.Sprintf("tools.%d.custom", i))
  301. if err != nil {
  302. // 删除失败不影响整体流程,跳过
  303. continue
  304. }
  305. }
  306. return body
  307. }
  308. // claudeVersionRe 匹配 Claude 模型 ID 中的版本号部分
  309. // 支持 claude-{tier}-{major}-{minor} 和 claude-{tier}-{major}.{minor} 格式;
  310. // minor 可选,用于覆盖只有主版本号的新模型 ID(claude-opus-5 / claude-sonnet-5),
  311. // 此时 matches[2] 为空串,调用方 strconv.Atoi 得到 minor=0。
  312. // 缺少可选 minor 时这类 ID 完全不匹配,会被当作"旧模型"降级处理:
  313. // thinking.enabled 不转 adaptive(Opus 5 上游已移除 budget_tokens,直接 400)、
  314. // cache_control.ttl 被剥离、tool search 被过滤。
  315. var claudeVersionRe = regexp.MustCompile(`claude-(?:haiku|sonnet|opus)-(\d+)(?:[-.](\d+))?`)
  316. // isBedrockClaude45OrNewer 判断 Bedrock 模型 ID 是否为 Claude 4.5 或更新版本
  317. // Claude 4.5+ 支持 cache_control 中的 ttl 字段("5m" 和 "1h")
  318. func isBedrockClaude45OrNewer(modelID string) bool {
  319. lower := strings.ToLower(modelID)
  320. if isBedrockFable5(lower) {
  321. return true
  322. }
  323. matches := claudeVersionRe.FindStringSubmatch(lower)
  324. if matches == nil {
  325. return false
  326. }
  327. major, _ := strconv.Atoi(matches[1])
  328. minor, _ := strconv.Atoi(matches[2])
  329. return major > 4 || (major == 4 && minor >= 5)
  330. }
  331. // sanitizeBedrockCacheControl 清理 system 和 messages 中 cache_control 里
  332. // Bedrock 不支持的字段:
  333. // - scope:Bedrock 不支持(如 "global" 跨请求缓存)
  334. // - ttl:仅 Claude 4.5+ 支持 "5m" 和 "1h",旧模型需要移除
  335. func sanitizeBedrockCacheControl(body []byte, modelID string) []byte {
  336. isClaude45 := isBedrockClaude45OrNewer(modelID)
  337. // 清理 system 数组中的 cache_control
  338. systemArr := gjson.GetBytes(body, "system")
  339. if systemArr.Exists() && systemArr.IsArray() {
  340. for i, item := range systemArr.Array() {
  341. if !item.IsObject() {
  342. continue
  343. }
  344. cc := item.Get("cache_control")
  345. if !cc.Exists() || !cc.IsObject() {
  346. continue
  347. }
  348. body = deleteCacheControlUnsupportedFields(body, fmt.Sprintf("system.%d.cache_control", i), cc, isClaude45)
  349. }
  350. }
  351. // 清理 messages 中的 cache_control
  352. messages := gjson.GetBytes(body, "messages")
  353. if !messages.Exists() || !messages.IsArray() {
  354. return body
  355. }
  356. for mi, msg := range messages.Array() {
  357. if !msg.IsObject() {
  358. continue
  359. }
  360. content := msg.Get("content")
  361. if !content.Exists() || !content.IsArray() {
  362. continue
  363. }
  364. for ci, block := range content.Array() {
  365. if !block.IsObject() {
  366. continue
  367. }
  368. cc := block.Get("cache_control")
  369. if !cc.Exists() || !cc.IsObject() {
  370. continue
  371. }
  372. body = deleteCacheControlUnsupportedFields(body, fmt.Sprintf("messages.%d.content.%d.cache_control", mi, ci), cc, isClaude45)
  373. }
  374. }
  375. return body
  376. }
  377. // deleteCacheControlUnsupportedFields 删除给定 cache_control 路径下 Bedrock 不支持的字段
  378. func deleteCacheControlUnsupportedFields(body []byte, basePath string, cc gjson.Result, isClaude45 bool) []byte {
  379. // Bedrock 不支持 scope(如 "global")
  380. if cc.Get("scope").Exists() {
  381. body, _ = sjson.DeleteBytes(body, basePath+".scope")
  382. }
  383. // ttl:仅 Claude 4.5+ 支持 "5m" 和 "1h",其余情况移除
  384. ttl := cc.Get("ttl")
  385. if ttl.Exists() {
  386. shouldRemove := true
  387. if isClaude45 {
  388. v := ttl.String()
  389. if v == "5m" || v == "1h" {
  390. shouldRemove = false
  391. }
  392. }
  393. if shouldRemove {
  394. body, _ = sjson.DeleteBytes(body, basePath+".ttl")
  395. }
  396. }
  397. return body
  398. }
  399. // parseAnthropicBetaHeader 解析 anthropic-beta 头的逗号分隔字符串为 token 列表
  400. func parseAnthropicBetaHeader(header string) []string {
  401. header = strings.TrimSpace(header)
  402. if header == "" {
  403. return nil
  404. }
  405. if strings.HasPrefix(header, "[") && strings.HasSuffix(header, "]") {
  406. var parsed []any
  407. if err := json.Unmarshal([]byte(header), &parsed); err == nil {
  408. tokens := make([]string, 0, len(parsed))
  409. for _, item := range parsed {
  410. token := strings.TrimSpace(fmt.Sprint(item))
  411. if token != "" {
  412. tokens = append(tokens, token)
  413. }
  414. }
  415. return tokens
  416. }
  417. }
  418. var tokens []string
  419. for _, part := range strings.Split(header, ",") {
  420. t := strings.TrimSpace(part)
  421. if t != "" {
  422. tokens = append(tokens, t)
  423. }
  424. }
  425. return tokens
  426. }
  427. // bedrockSupportedBetaTokens 是 Bedrock Invoke 支持的 beta 头白名单
  428. // 参考: AWS Bedrock 官方文档 + litellm anthropic_beta_headers_config.json
  429. // 更新策略: 当 AWS Bedrock 新增支持的 beta token 时需同步更新此白名单
  430. var bedrockSupportedBetaTokens = map[string]bool{
  431. "computer-use-2025-01-24": true,
  432. "computer-use-2025-11-24": true,
  433. "context-1m-2025-08-07": true,
  434. "context-management-2025-06-27": true, // compaction + clear_thinking,AWS 文档已支持
  435. "compact-2026-01-12": true, // 官方支持,仅 InvokeModel API(Opus 4.6+)
  436. "fine-grained-tool-streaming-2025-05-14": true, // AWS Tool Use 文档已支持
  437. // "interleaved-thinking-2025-05-14": false, // 无官方文档支持
  438. "tool-search-tool-2025-10-19": true,
  439. "tool-examples-2025-10-29": true,
  440. }
  441. const bedrockContextManagementBetaToken = "context-management-2025-06-27"
  442. // bedrockBetaTokenTransforms 定义 Bedrock Invoke 特有的 beta 头转换规则
  443. // Anthropic 直接 API 使用通用头,Bedrock Invoke 需要特定的替代头
  444. var bedrockBetaTokenTransforms = map[string]string{
  445. "advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",
  446. }
  447. // autoInjectBedrockBetaTokens 根据请求体内容自动补齐必要的 beta token
  448. // 参考 litellm: AnthropicModelInfo.get_anthropic_beta_list() 和
  449. // AmazonAnthropicClaudeMessagesConfig._get_tool_search_beta_header_for_bedrock()
  450. //
  451. // 客户端(特别是非 Claude Code 客户端)可能只在 body 中启用了功能而不在 header 中带对应 beta token,
  452. // 这里通过检测请求体特征自动补齐,确保 Bedrock Invoke 不会因缺少必要 beta 头而 400。
  453. func autoInjectBedrockBetaTokens(tokens []string, body []byte, modelID string) []string {
  454. seen := make(map[string]bool, len(tokens))
  455. for _, t := range tokens {
  456. seen[t] = true
  457. }
  458. inject := func(token string) {
  459. if !seen[token] {
  460. tokens = append(tokens, token)
  461. seen[token] = true
  462. }
  463. }
  464. // 注意:thinking 字段不再自动注入 interleaved-thinking-2025-05-14
  465. // 因为该 beta token 未在 AWS Bedrock 官方文档中确认支持
  466. // 检测 computer_use 工具
  467. // tools 中有 type="computer_20xxxxxx" 的工具 → 需要 computer-use beta
  468. tools := gjson.GetBytes(body, "tools")
  469. if tools.Exists() && tools.IsArray() {
  470. toolSearchUsed := false
  471. programmaticToolCallingUsed := false
  472. inputExamplesUsed := false
  473. for _, tool := range tools.Array() {
  474. toolType := tool.Get("type").String()
  475. if strings.HasPrefix(toolType, "computer_20") {
  476. inject("computer-use-2025-11-24")
  477. }
  478. if isBedrockToolSearchType(toolType) {
  479. toolSearchUsed = true
  480. }
  481. if hasCodeExecutionAllowedCallers(tool) {
  482. programmaticToolCallingUsed = true
  483. }
  484. if hasInputExamples(tool) {
  485. inputExamplesUsed = true
  486. }
  487. }
  488. if programmaticToolCallingUsed || inputExamplesUsed {
  489. // programmatic tool calling 和 input examples 需要 advanced-tool-use,
  490. // 后续 filterBedrockBetaTokens 会将其转换为 Bedrock 特定的 tool-search-tool
  491. inject("advanced-tool-use-2025-11-20")
  492. }
  493. if toolSearchUsed && bedrockModelSupportsToolSearch(modelID) {
  494. // 纯 tool search(无 programmatic/inputExamples)时直接注入 Bedrock 特定头,
  495. // 跳过 advanced-tool-use → tool-search-tool 的转换步骤(与 litellm 对齐)
  496. if !programmaticToolCallingUsed && !inputExamplesUsed {
  497. inject("tool-search-tool-2025-10-19")
  498. } else {
  499. inject("advanced-tool-use-2025-11-20")
  500. }
  501. }
  502. }
  503. return tokens
  504. }
  505. func isBedrockToolSearchType(toolType string) bool {
  506. return toolType == "tool_search_tool_regex_20251119" || toolType == "tool_search_tool_bm25_20251119"
  507. }
  508. func hasCodeExecutionAllowedCallers(tool gjson.Result) bool {
  509. allowedCallers := tool.Get("allowed_callers")
  510. if containsStringInJSONArray(allowedCallers, "code_execution_20250825") {
  511. return true
  512. }
  513. return containsStringInJSONArray(tool.Get("function.allowed_callers"), "code_execution_20250825")
  514. }
  515. func hasInputExamples(tool gjson.Result) bool {
  516. if arr := tool.Get("input_examples"); arr.Exists() && arr.IsArray() && len(arr.Array()) > 0 {
  517. return true
  518. }
  519. arr := tool.Get("function.input_examples")
  520. return arr.Exists() && arr.IsArray() && len(arr.Array()) > 0
  521. }
  522. func containsStringInJSONArray(result gjson.Result, target string) bool {
  523. if !result.Exists() || !result.IsArray() {
  524. return false
  525. }
  526. for _, item := range result.Array() {
  527. if item.String() == target {
  528. return true
  529. }
  530. }
  531. return false
  532. }
  533. // bedrockModelSupportsToolSearch 判断 Bedrock 模型是否支持 tool search
  534. // 目前仅 Claude Opus/Sonnet 4.5+ 支持,Haiku 不支持
  535. func bedrockModelSupportsToolSearch(modelID string) bool {
  536. lower := strings.ToLower(modelID)
  537. matches := claudeVersionRe.FindStringSubmatch(lower)
  538. if matches == nil {
  539. return false
  540. }
  541. // Haiku 不支持 tool search
  542. if strings.Contains(lower, "haiku") {
  543. return false
  544. }
  545. major, _ := strconv.Atoi(matches[1])
  546. minor, _ := strconv.Atoi(matches[2])
  547. return major > 4 || (major == 4 && minor >= 5)
  548. }
  549. // filterBedrockBetaTokens 过滤并转换 beta token 列表,仅保留 Bedrock Invoke 支持的 token
  550. // 1. 应用转换规则(如 advanced-tool-use → tool-search-tool)
  551. // 2. 过滤掉 Bedrock 不支持的 token(如 output-128k, files-api, structured-outputs 等)
  552. // 3. 自动关联 tool-examples(当 tool-search-tool 存在时)
  553. func filterBedrockBetaTokens(tokens []string) []string {
  554. seen := make(map[string]bool, len(tokens))
  555. var result []string
  556. for _, t := range tokens {
  557. // 应用转换规则
  558. if replacement, ok := bedrockBetaTokenTransforms[t]; ok {
  559. t = replacement
  560. }
  561. // 只保留白名单中的 token,且去重
  562. if bedrockSupportedBetaTokens[t] && !seen[t] {
  563. result = append(result, t)
  564. seen[t] = true
  565. }
  566. }
  567. // 自动关联: tool-search-tool 存在时,确保 tool-examples 也存在
  568. if seen["tool-search-tool-2025-10-19"] && !seen["tool-examples-2025-10-29"] {
  569. result = append(result, "tool-examples-2025-10-29")
  570. }
  571. return result
  572. }
  573. // sanitizeBedrockFieldsForBetaTokens 与直连路径的 sanitizeAnthropicBodyForBetaTokens
  574. // 对称:按最终 Bedrock beta tokens 决定是否保留 body 中的 beta 字段。
  575. // - context_management 缺 context-management beta → strip
  576. // - fallbacks 缺 server-side-fallback beta → strip
  577. // - fallback_credit_token 缺 server-side-fallback / 任一 fallback-credit beta → strip
  578. //
  579. // 注意:fallback beta token 均不在 bedrockSupportedBetaTokens 白名单内
  580. // (会被 filterBedrockBetaTokens 过滤掉),因此条件 strip 实际总会剥除——这是预期:
  581. // Bedrock Invoke 不支持这些 beta 字段。
  582. func sanitizeBedrockFieldsForBetaTokens(body []byte, betaTokens []string) []byte {
  583. if !containsBedrockBetaToken(betaTokens, bedrockContextManagementBetaToken) && gjson.GetBytes(body, "context_management").Exists() {
  584. body, _ = sjson.DeleteBytes(body, "context_management")
  585. }
  586. if !containsBedrockBetaToken(betaTokens, claude.BetaServerSideFallback) && gjson.GetBytes(body, "fallbacks").Exists() {
  587. body, _ = sjson.DeleteBytes(body, "fallbacks")
  588. }
  589. if !containsAnyBedrockBetaToken(betaTokens, claude.BetaServerSideFallback, claude.BetaFallbackCredit, claude.BetaFallbackCreditLegacy) &&
  590. gjson.GetBytes(body, "fallback_credit_token").Exists() {
  591. body, _ = sjson.DeleteBytes(body, "fallback_credit_token")
  592. }
  593. return body
  594. }
  595. func containsBedrockBetaToken(tokens []string, target string) bool {
  596. for _, token := range tokens {
  597. if token == target {
  598. return true
  599. }
  600. }
  601. return false
  602. }
  603. // containsAnyBedrockBetaToken 判断 tokens 是否包含 targets 中的任意一个 token。
  604. func containsAnyBedrockBetaToken(tokens []string, targets ...string) bool {
  605. for _, target := range targets {
  606. if containsBedrockBetaToken(tokens, target) {
  607. return true
  608. }
  609. }
  610. return false
  611. }
  612. // bedrockToolUseIDRe 匹配 Bedrock 允许的 tool_use ID 字符(字母、数字、下划线、连字符)
  613. var bedrockToolUseIDRe = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
  614. // isBedrockOpus47OrNewer 判断 Bedrock 模型 ID 是否为 Claude Opus 4.7 或更新版本
  615. // Opus 4.7 仅支持 thinking.type: "adaptive",不支持 "enabled"
  616. func isBedrockOpus47OrNewer(modelID string) bool {
  617. lower := strings.ToLower(modelID)
  618. if !strings.Contains(lower, "opus") {
  619. return false
  620. }
  621. matches := claudeVersionRe.FindStringSubmatch(lower)
  622. if matches == nil {
  623. return false
  624. }
  625. major, _ := strconv.Atoi(matches[1])
  626. minor, _ := strconv.Atoi(matches[2])
  627. return major > 4 || (major == 4 && minor >= 7)
  628. }
  629. func isBedrockFable5(modelID string) bool {
  630. return strings.Contains(strings.ToLower(modelID), "claude-fable-5")
  631. }
  632. const defaultThinkingBudgetTokens = 10000
  633. // sanitizeBedrockThinking 修复 thinking 字段的 Bedrock 兼容性问题:
  634. // - Fable 5: 仅使用 always-on adaptive thinking,不支持手动 budget_tokens
  635. // - Opus 4.7+: 仅支持 "adaptive",将 "enabled" 转换为 "adaptive" 并移除 budget_tokens
  636. // - 其他模型: "enabled" 必须带 budget_tokens,缺失时补充默认值
  637. func sanitizeBedrockThinking(body []byte, modelID string) []byte {
  638. thinking := gjson.GetBytes(body, "thinking")
  639. if !thinking.Exists() || !thinking.IsObject() {
  640. return body
  641. }
  642. thinkingType := thinking.Get("type").String()
  643. if thinkingType == "" {
  644. return body
  645. }
  646. if isBedrockFable5(modelID) {
  647. if thinkingType == "enabled" {
  648. body, _ = sjson.SetBytes(body, "thinking.type", "adaptive")
  649. }
  650. if thinkingType == "enabled" || thinkingType == "adaptive" {
  651. body, _ = sjson.DeleteBytes(body, "thinking.budget_tokens")
  652. }
  653. return body
  654. }
  655. if isBedrockOpus47OrNewer(modelID) {
  656. if thinkingType == "enabled" {
  657. body, _ = sjson.SetBytes(body, "thinking.type", "adaptive")
  658. body, _ = sjson.DeleteBytes(body, "thinking.budget_tokens")
  659. }
  660. return body
  661. }
  662. if thinkingType == "enabled" && !thinking.Get("budget_tokens").Exists() {
  663. body, _ = sjson.SetBytes(body, "thinking.budget_tokens", defaultThinkingBudgetTokens)
  664. }
  665. return body
  666. }
  667. // sanitizeBedrockToolUseIDs 清理 messages 中 tool_use.id 和 tool_result.tool_use_id
  668. // 的非法字符。Bedrock 要求 ID 匹配 '^[a-zA-Z0-9_-]+$'。
  669. func sanitizeBedrockToolUseIDs(body []byte) []byte {
  670. messages := gjson.GetBytes(body, "messages")
  671. if !messages.Exists() || !messages.IsArray() {
  672. return body
  673. }
  674. for mi, msg := range messages.Array() {
  675. content := msg.Get("content")
  676. if !content.Exists() || !content.IsArray() {
  677. continue
  678. }
  679. for ci, block := range content.Array() {
  680. switch block.Get("type").String() {
  681. case "tool_use":
  682. body = sanitizeIDField(body, block.Get("id").String(), fmt.Sprintf("messages.%d.content.%d.id", mi, ci))
  683. case "tool_result":
  684. body = sanitizeIDField(body, block.Get("tool_use_id").String(), fmt.Sprintf("messages.%d.content.%d.tool_use_id", mi, ci))
  685. }
  686. }
  687. }
  688. return body
  689. }
  690. func sanitizeIDField(body []byte, id, path string) []byte {
  691. if id == "" {
  692. return body
  693. }
  694. sanitized := bedrockToolUseIDRe.ReplaceAllString(id, "_")
  695. if sanitized != id {
  696. body, _ = sjson.SetBytes(body, path, sanitized)
  697. }
  698. return body
  699. }
  700. const defaultCCMaxTokens = 81920
  701. // sanitizeBedrockCCFields 处理 Claude Code 发送的 Bedrock 不兼容字段:
  702. // - 移除 service_tier(Anthropic API 专有,Bedrock 不支持)
  703. // - 移除 interface_geo(Anthropic API 专有,Bedrock 不支持)
  704. // - 移除 context_management(Anthropic API 专有,Bedrock 不支持,CC v2.1.87+ 默认携带)
  705. // - 无条件移除 fallbacks / fallback_credit_token(server-side refusal fallback,
  706. // Anthropic 直连 beta API 专有;Bedrock Invoke 无对应 beta,永不支持)
  707. // - 注入 max_tokens 默认值 81920(CC 可能省略,Bedrock 要求必须提供)
  708. // - 注入 anthropic_version(CC 通过 HTTP 头发送,Bedrock 需要放在请求体中)
  709. func sanitizeBedrockCCFields(body []byte) []byte {
  710. if gjson.GetBytes(body, "service_tier").Exists() {
  711. body, _ = sjson.DeleteBytes(body, "service_tier")
  712. }
  713. if gjson.GetBytes(body, "interface_geo").Exists() {
  714. body, _ = sjson.DeleteBytes(body, "interface_geo")
  715. }
  716. if gjson.GetBytes(body, "context_management").Exists() {
  717. body, _ = sjson.DeleteBytes(body, "context_management")
  718. }
  719. if gjson.GetBytes(body, "fallbacks").Exists() {
  720. body, _ = sjson.DeleteBytes(body, "fallbacks")
  721. }
  722. if gjson.GetBytes(body, "fallback_credit_token").Exists() {
  723. body, _ = sjson.DeleteBytes(body, "fallback_credit_token")
  724. }
  725. if !gjson.GetBytes(body, "max_tokens").Exists() {
  726. body, _ = sjson.SetBytes(body, "max_tokens", defaultCCMaxTokens)
  727. }
  728. if !gjson.GetBytes(body, "anthropic_version").Exists() {
  729. body, _ = sjson.SetBytes(body, "anthropic_version", "bedrock-2023-05-31")
  730. }
  731. return body
  732. }
  733. // sanitizeBedrockCCBetaTokens 清理请求体中的 anthropic_beta 字段,只保留 Bedrock 支持的 beta token
  734. // CC 可能在请求体中注入了 Bedrock 不支持的 beta token(如 prompt-caching 等),导致 ValidationException
  735. func sanitizeBedrockCCBetaTokens(body []byte, modelID string) []byte {
  736. betaField := gjson.GetBytes(body, "anthropic_beta")
  737. if !betaField.Exists() {
  738. return body
  739. }
  740. var tokens []string
  741. if betaField.IsArray() {
  742. for _, t := range betaField.Array() {
  743. if t.Type == gjson.String {
  744. tokens = append(tokens, t.String())
  745. }
  746. }
  747. }
  748. originalTokens := append([]string(nil), tokens...) // 保存原始 tokens 用于日志
  749. // 复用现有的 Bedrock beta token 过滤逻辑(自动注入 + 白名单过滤 + 转换)
  750. // 即使 tokens 为空,也要执行自动注入(根据 body 内容补充必要的 beta token)
  751. tokens = autoInjectBedrockBetaTokens(tokens, body, modelID)
  752. tokens = filterBedrockBetaTokens(tokens)
  753. if len(tokens) == 0 {
  754. // 所有 token 都被过滤掉,删除 anthropic_beta 字段
  755. body, _ = sjson.DeleteBytes(body, "anthropic_beta")
  756. logger.LegacyPrintf("service.gateway", "[Bedrock CC Compat] Removed all beta tokens: original=%v", originalTokens)
  757. } else {
  758. // 更新为过滤后的 token 列表
  759. body, _ = sjson.SetBytes(body, "anthropic_beta", tokens)
  760. if len(originalTokens) > 0 {
  761. logger.LegacyPrintf("service.gateway", "[Bedrock CC Compat] Filtered beta tokens: original=%v final=%v", originalTokens, tokens)
  762. } else {
  763. logger.LegacyPrintf("service.gateway", "[Bedrock CC Compat] Auto-injected beta tokens: %v", tokens)
  764. }
  765. }
  766. return body
  767. }