From 15cd9721413848bfa0a0d4fdd2ca442362796dd0 Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Fri, 21 Aug 2026 23:40:36 +0800 Subject: [PATCH 01/18] refactor(mod_ai_token_auth): simplify token_rule.data fields and add TagLevel - Remove QuotaPlan.CreateTime/ResetMode from token_rule.data. - Remove Token.status/update_time; change Token.Enabled from int to bool. - Add TagLevel to ApikeyTag and access log protobuf. - Update validation, auth logic, tests, testdata and docs accordingly. - Upgrade bfe-access-pb to v0.3.0. --- bfe_basic/request_ai_basic.go | 1 + bfe_modules/mod_access_pb3/request_log.go | 1 + .../mod_ai_token_auth_test.go | 25 +- .../mod_ai_token_auth/token_rule.data | 7 +- bfe_modules/mod_ai_token_auth/token.go | 22 +- .../mod_ai_token_auth/token_rule_load.go | 3 - .../mod_ai_token_auth/token_rule_table.go | 26 +- .../mod_ai_token_auth/token_rule.data.md | 221 +++++--- .../design-changes.md | 511 ++++++++++++++++++ docs/zh_cn/sys_design/rmb_quota.md | 4 +- go.mod | 3 +- go.sum | 4 +- .../integration/common/bfe_config_builder.go | 6 +- .../sc03_rmb_quota_test.go | 8 +- .../sc05_access_log_ai_fields_test.go | 8 +- ...72\346\231\257\350\257\264\346\230\216.md" | 4 - ...72\346\231\257\350\257\264\346\230\216.md" | 4 - 17 files changed, 680 insertions(+), 178 deletions(-) create mode 100644 docs/zh_cn/modifications/2026-08-21-token-rule-data-field-simplification/design-changes.md diff --git a/bfe_basic/request_ai_basic.go b/bfe_basic/request_ai_basic.go index b34606648..f3b987d61 100644 --- a/bfe_basic/request_ai_basic.go +++ b/bfe_basic/request_ai_basic.go @@ -49,6 +49,7 @@ type TokenTimeInfo struct { type ApikeyTag struct { TagName string //eg entity.type TagValue string //eg entity.name + TagLevel int // entity type level, value range 1~5 } type AiAuthInfo struct { diff --git a/bfe_modules/mod_access_pb3/request_log.go b/bfe_modules/mod_access_pb3/request_log.go index c39388f56..8dfec8845 100644 --- a/bfe_modules/mod_access_pb3/request_log.go +++ b/bfe_modules/mod_access_pb3/request_log.go @@ -382,6 +382,7 @@ func reqAiInfoGen(reqLog *bfe_access_pb3.RequestLog, req *bfe_basic.Request, res reqLog.AiApikeytags = append(reqLog.AiApikeytags, &bfe_access_pb3.ApikeyTag{ Tagname: proto.String(tag.TagName), Tagvalue: proto.String(tag.TagValue), + Taglevel: proto.Int32(int32(tag.TagLevel)), }) } } diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go index 78e3f6bf9..805dd487b 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go @@ -351,7 +351,7 @@ func TestTokenCheck(t *testing.T) { return &TokenFile{ Key: "ak-123", KeyId: "ak-123-id", - Status: TokenStatusEnabled, + Enabled: true, ExpiredTime: -1, UnlimitedQuota: true, } @@ -380,13 +380,6 @@ func TestTokenCheck(t *testing.T) { }, errSub: "no KeyId", }, - { - name: "invalid status", - mutate: func(tf *TokenFile) { - tf.Status = 0 - }, - errSub: "invalid Status", - }, { name: "invalid expired time", mutate: func(tf *TokenFile) { @@ -446,7 +439,7 @@ func TestTokenConvert(t *testing.T) { tf := TokenFile{ Key: "ak-123", KeyId: "ak-123-id", - Status: TokenStatusEnabled, + Enabled: true, ExpiredTime: -1, UnlimitedQuota: false, QuotaPlans: []string{"plan1"}, @@ -537,18 +530,13 @@ func TestValidateUserToken(t *testing.T) { t.Errorf("unexpected token: %s", tok.Key) } - exhausted := &Token{Key: "ak-exhausted", KeyId: "ak-exhausted-id", Status: TokenStatusExhausted, ExpiredTime: -1} - disabled := &Token{Key: "ak-disabled", KeyId: "ak-disabled-id", Status: TokenStatusDisabled, ExpiredTime: -1} - expired := &Token{Key: "ak-expired", KeyId: "ak-expired-id", Status: TokenStatusEnabled, ExpiredTime: time.Now().Unix() - 10} + disabled := &Token{Key: "ak-disabled", KeyId: "ak-disabled-id", Enabled: false, ExpiredTime: -1} + expired := &Token{Key: "ak-expired", KeyId: "ak-expired-id", Enabled: true, ExpiredTime: time.Now().Unix() - 10} table.lock.Lock() - (*table.productTokens["AI_product"])["ak-exhausted"] = exhausted (*table.productTokens["AI_product"])["ak-disabled"] = disabled (*table.productTokens["AI_product"])["ak-expired"] = expired table.lock.Unlock() - if _, err := table.ValidateUserToken("AI_product", "ak-exhausted"); err == nil { - t.Error("expected error for exhausted token") - } if _, err := table.ValidateUserToken("AI_product", "ak-disabled"); err == nil { t.Error("expected error for disabled token") } @@ -882,7 +870,7 @@ func TestTokenRuleCheck(t *testing.T) { } func TestQuotaPlanCheck(t *testing.T) { - valid := QuotaPlan{Id: "p1", Unlimited: true, ExpiredTime: -1, ResetMode: 0} + valid := QuotaPlan{Id: "p1", Unlimited: true, ExpiredTime: -1} if err := quotaPlanCheck(&valid); err != nil { t.Errorf("valid quota plan failed: %s", err) } @@ -897,7 +885,6 @@ func TestQuotaPlanCheck(t *testing.T) { {"invalid token quota", QuotaPlan{Id: "p1", Unlimited: false, Quota: 0, Unit: "total_token"}, "invalid Quota"}, {"invalid rmb quota", QuotaPlan{Id: "p1", Unlimited: false, Quota: -1, Unit: "RMB"}, "invalid Quota for RMB"}, {"invalid unit", QuotaPlan{Id: "p1", Unlimited: true, Unit: "invalid"}, "invalid Unit"}, - {"invalid reset mode", QuotaPlan{Id: "p1", Unlimited: true, ResetMode: 2}, "invalid ResetMode"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -930,7 +917,7 @@ func TestSubnetValidation(t *testing.T) { tf := &TokenFile{ Key: "ak-subnet", KeyId: "ak-subnet-id", - Status: TokenStatusEnabled, + Enabled: true, ExpiredTime: -1, UnlimitedQuota: true, Subnet: &s, diff --git a/bfe_modules/mod_ai_token_auth/testdata/mod_ai_token_auth/token_rule.data b/bfe_modules/mod_ai_token_auth/testdata/mod_ai_token_auth/token_rule.data index e295a3c90..b2a52c8c6 100644 --- a/bfe_modules/mod_ai_token_auth/testdata/mod_ai_token_auth/token_rule.data +++ b/bfe_modules/mod_ai_token_auth/testdata/mod_ai_token_auth/token_rule.data @@ -17,10 +17,8 @@ "unlimited": true, "pass_no_quota": false, "redis_key": "", - "create_time": 0, "expired_time": -1, - "quota": 0, - "reset_mode": 0 + "quota": 0 } ] }, @@ -29,8 +27,7 @@ "ak-123": { "key": "ak-123", "key_id": "ak-123-id", - "status": 1, - "update_time": 0, + "enabled": true, "expired_time": -1, "unlimited_quota": true, "allow_models": null, diff --git a/bfe_modules/mod_ai_token_auth/token.go b/bfe_modules/mod_ai_token_auth/token.go index a139528bb..2446b6709 100644 --- a/bfe_modules/mod_ai_token_auth/token.go +++ b/bfe_modules/mod_ai_token_auth/token.go @@ -27,13 +27,6 @@ import ( "github.com/google/uuid" ) -const ( - TokenStatusEnabled = 1 - TokenStatusDisabled = 2 - TokenStatusExpired = 3 - TokenStatusExhausted = 4 -) - const ( ActionCheckToken = "CHECK_TOKEN" ) @@ -41,8 +34,7 @@ const ( type Token struct { Key string KeyId string - Status int - UpdateTime int64 + Enabled bool ExpiredTime int64 UnlimitedQuota bool Models []string @@ -55,9 +47,7 @@ type Token struct { type TokenFile struct { Key string `json:"key"` KeyId string `json:"key_id"` - Enabled int `json:"enabled"` - Status int `json:"status"` - UpdateTime int64 `json:"update_time"` + Enabled bool `json:"enabled"` ExpiredTime int64 `json:"expired_time"` // -1 means never expired UnlimitedQuota bool `json:"unlimited_quota"` Models *string `json:"allow_models"` // allowed models @@ -75,10 +65,8 @@ type QuotaPlan struct { Unlimited bool PassNoQuota bool RedisKey string - CreateTime int64 ExpiredTime int64 // -1 means never expired Quota int64 // 配额总量,固定点整数:total_token 时为 Token 数;RMB 时为 1e-8 元 - ResetMode int // 0 – 非周期性;1 – 周期性的配额包 Unit string // "total_token" or "RMB" } @@ -181,9 +169,6 @@ func tokenCheck(conf *TokenFile) error { if conf.KeyId == "" { return errors.New("no KeyId") } - if conf.Status < TokenStatusEnabled || conf.Status > TokenStatusExhausted { - return fmt.Errorf("invalid Status: %d", conf.Status) - } if conf.ExpiredTime < -1 { return fmt.Errorf("invalid ExpiredTime: %d", conf.ExpiredTime) } @@ -239,8 +224,7 @@ func tokenConvert(tokenFile TokenFile, quotaPlansMap *QuotaPlanMap) (Token, erro return Token{ Key: tokenFile.Key, KeyId: tokenFile.KeyId, - Status: tokenFile.Status, - UpdateTime: tokenFile.UpdateTime, + Enabled: tokenFile.Enabled, ExpiredTime: tokenFile.ExpiredTime, UnlimitedQuota: tokenFile.UnlimitedQuota, Models: tokenFile.models, diff --git a/bfe_modules/mod_ai_token_auth/token_rule_load.go b/bfe_modules/mod_ai_token_auth/token_rule_load.go index 45f63c02a..65ea6d3f7 100644 --- a/bfe_modules/mod_ai_token_auth/token_rule_load.go +++ b/bfe_modules/mod_ai_token_auth/token_rule_load.go @@ -123,9 +123,6 @@ func quotaPlanCheck(conf *QuotaPlan) error { if conf.ExpiredTime < -1 { return fmt.Errorf("invalid ExpiredTime: %d", conf.ExpiredTime) } - if conf.ResetMode < 0 || conf.ResetMode > 1 { - return fmt.Errorf("invalid ResetMode: %d", conf.ResetMode) - } // backward compatibility: empty unit means total_token if conf.Unit == "" { diff --git a/bfe_modules/mod_ai_token_auth/token_rule_table.go b/bfe_modules/mod_ai_token_auth/token_rule_table.go index 4a3df7d3c..5605c5154 100644 --- a/bfe_modules/mod_ai_token_auth/token_rule_table.go +++ b/bfe_modules/mod_ai_token_auth/token_rule_table.go @@ -78,17 +78,11 @@ func (t *TokenRuleTable) ValidateUserToken(product, key string) (token *Token, e return nil, errors.New("token not found") } - switch token.Status { - case TokenStatusExhausted: - return nil, fmt.Errorf("token %s quota exhausted", token.KeyId) - case TokenStatusExpired: - return nil, fmt.Errorf("token %s expired", token.KeyId) - case TokenStatusDisabled: + if !token.Enabled { return nil, fmt.Errorf("token %s disabled", token.KeyId) } if token.ExpiredTime != -1 && token.ExpiredTime < time.Now().Unix() { - token.Status = TokenStatusExpired return nil, fmt.Errorf("token %s expired", token.KeyId) } @@ -131,22 +125,7 @@ func (m *ModuleAITokenAuth) ValidateUserTokenByReq(req *bfe_basic.Request) (toke aiBasicInfo.ClientKeyId = token.KeyId } - switch token.Status { - case TokenStatusExhausted: - SetAiAuthInfo(req, bfe_basic.CodeInvalidApiKey, nil) - return nil, bfe_basic.NewAiErrorWithDetails(bfe_basic.CodeInvalidApiKey, bfe_basic.TypeAuthenticationError, fmt.Sprintf("Invalid API key: %s. quota exhausted.", key), - &bfe_basic.AiErrorDetail{ - ApiKey: key, - KeyId: token.KeyId, - }) - case TokenStatusExpired: - SetAiAuthInfo(req, bfe_basic.CodeKeyExpired, nil) - return nil, bfe_basic.NewAiErrorWithDetails(bfe_basic.CodeKeyExpired, bfe_basic.TypeAuthenticationError, fmt.Sprintf("Invalid API key: %s. expired.", key), - &bfe_basic.AiErrorDetail{ - ApiKey: key, - KeyId: token.KeyId, - }) - case TokenStatusDisabled: + if !token.Enabled { SetAiAuthInfo(req, bfe_basic.CodeKeyDisabled, nil) return nil, bfe_basic.NewAiErrorWithDetails(bfe_basic.CodeKeyDisabled, bfe_basic.TypeAuthenticationError, fmt.Sprintf("Invalid API key: %s. disabled.", key), &bfe_basic.AiErrorDetail{ @@ -156,7 +135,6 @@ func (m *ModuleAITokenAuth) ValidateUserTokenByReq(req *bfe_basic.Request) (toke } if token.ExpiredTime != -1 && token.ExpiredTime < time.Now().Unix() { - token.Status = TokenStatusExpired SetAiAuthInfo(req, bfe_basic.CodeKeyExpired, nil) return nil, bfe_basic.NewAiErrorWithDetails(bfe_basic.CodeKeyExpired, bfe_basic.TypeAuthenticationError, fmt.Sprintf("Invalid API key: %s. expired.", key), &bfe_basic.AiErrorDetail{ diff --git a/docs/zh_cn/configuration/mod_ai_token_auth/token_rule.data.md b/docs/zh_cn/configuration/mod_ai_token_auth/token_rule.data.md index fc5890341..02199765f 100644 --- a/docs/zh_cn/configuration/mod_ai_token_auth/token_rule.data.md +++ b/docs/zh_cn/configuration/mod_ai_token_auth/token_rule.data.md @@ -1,83 +1,154 @@ # mod_ai_token_auth 规则配置 -## 配置简介 +## 1. 配置简介 `token_rule.data` 是 `mod_ai_token_auth` 模块的规则配置文件,用于声明 api-key、配额计划以及按产品线的鉴权规则。 -## 配置描述 - -| 配置项 | 类型 | 参数含义 | 必填 | 补充描述 | 合法性条件 | -| ------------------------------ | ------- | -------------------------------- | ---- | ------------------------------------------------------------ | ------------------------------------------------------------ | -| Version | String | 配置文件版本 | Y | 通常采用时间戳格式,如 `20190101000000` | 类型为 [Version](../00-common.md#5-配置文件版本version) | -| QuotaPlans | Object | 所有产品线的配额计划声明 | Y | 以产品线名称为键 | - | -| QuotaPlans{k} | String | 产品线名称 | Y | - | - | -| QuotaPlans{v} | Array | 产品线下的配额计划列表 | Y | - | - | -| QuotaPlans{v}[] | Object | 配额计划 | Y | - | - | -| QuotaPlans{v}[].id | String | 配额计划 ID | Y | - | - | -| QuotaPlans{v}[].unlimited | Boolean | 是否无限配额 | Y | - | - | -| QuotaPlans{v}[].pass_no_quota | Boolean | 配额不足时是否放行 | Y | - | - | -| QuotaPlans{v}[].redis_key | String | Redis 中存储配额的 key | N | unlimited 为 true 时可不配置 | - | -| QuotaPlans{v}[].create_time | Integer | 创建时间(Unix Time) | N | - | - | -| QuotaPlans{v}[].expired_time | Integer | 过期时间(Unix Time) | N | `-1` 表示永不过期 | 必须大于等于 `-1` | -| QuotaPlans{v}[].quota | Integer | 配额总量 | N | 单位由 `unit` 字段决定;`unit=RMB` 时为定点整数,精度 `1e-8` 元;unlimited 为 false 时必填 | `unit=total_token` 且 unlimited 为 false 时必须大于 0;`unit=RMB` 且 unlimited 为 false 时必须大于等于 0 | -| QuotaPlans{v}[].reset_mode | Integer | 重置模式 | Y | `0` - 非周期性;`1` - 周期性的配额包 | 取值范围为 `0`、`1` | -| QuotaPlans{v}[].unit | String | 配额单位 | N | 默认 `total_token` | 取值为 `total_token` 或 `RMB` | -| Tokens | Object | 所有产品线的 api-key 声明 | Y | 以产品线名称为键 | - | -| Tokens{k} | String | 产品线名称 | Y | - | - | -| Tokens{v} | Object | 该产品线下的所有 api-key | Y | - | - | -| Tokens{v}{k} | String | api-key | Y | - | - | -| Tokens{v}{v} | Object | 一个 api-key 声明 | Y | - | - | -| Tokens{v}{v}.key | String | api-key | Y | 须与外层键一致 | - | -| Tokens{v}{v}.key_id | String | api-key 标识 ID | Y | 用于唯一标识该 api-key | 非空字符串 | -| Tokens{v}{v}.enabled | Integer | 是否启用 | N | - | - | -| Tokens{v}{v}.status | Integer | api-key 状态 | Y | `1` - Enabled;`2` - Disabled;`3` - Expired;`4` - Exhausted | 取值范围为 `1`、`2`、`3`、`4` | -| Tokens{v}{v}.update_time | Integer | 更新时间(Unix Time) | N | 改变意味着开启一个新的配额消费周期 | - | -| Tokens{v}{v}.expired_time | Integer | 过期时间(Unix Time) | N | `-1` 表示永不过期 | 必须大于等于 `-1` | -| Tokens{v}{v}.unlimited_quota | Boolean | 是否无限配额 | Y | - | - | -| Tokens{v}{v}.allow_models | String | 允许的模型列表 | N | 多个模型名以逗号分隔 | 不能包含空字符串 | -| Tokens{v}{v}.block_models | String | 禁止的模型列表 | N | 多个模型名以逗号分隔 | 不能包含空字符串 | -| Tokens{v}{v}.subnet | String | 允许的源 IP 子网 | N | 多个子网以逗号分隔 | 须为有效的 CIDR 格式 | -| Tokens{v}{v}.tags | Array | api-key 标签列表 | N | - | - | -| Tokens{v}{v}.tags[] | Object | api-key 标签 | N | - | - | -| Tokens{v}{v}.tags[].key | String | 标签名 | N | 如 `department` | - | -| Tokens{v}{v}.tags[].value | String | 标签值 | N | 如 `engineering` | - | -| Tokens{v}{v}.quota_plans | Array | 关联的配额计划 ID 列表 | N | unlimited_quota 为 false 时必填 | unlimited_quota 为 false 时须非空 | -| Config | Object | 所有产品线的 api-key 鉴权规则配置 | Y | 以产品线名称为键 | - | -| Config{k} | String | 产品线名称 | Y | - | - | -| Config{v} | Array | 产品线下 api-key 鉴权规则列表 | Y | - | - | -| Config{v}[] | Object | api-key 鉴权规则 | Y | - | - | -| Config{v}[].cond | String | 匹配条件 | Y | 语法详见 [Condition](../../condition/condition_grammar.md) | - | -| Config{v}[].action | Object | 动作 | Y | 只支持 `{ "cmd": "CHECK_TOKEN" }` | - | -| Config{v}[].action.cmd | String | 动作命令 | Y | 固定为 `CHECK_TOKEN` | 取值范围为 `CHECK_TOKEN` | - -## 配置示例 +## 2. 顶层结构 ```json { "Version": "20190101000000", + "QuotaPlans": { /* 按产品线分组的配额计划定义 */ }, + "Tokens": { /* 按产品线分组的 api-key 声明 */ }, + "Config": { /* 按产品线分组的 api-key 鉴权规则 */ } +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| Version | string | Y | 配置文件版本,通常采用时间戳格式,如 `20190101000000` | +| QuotaPlans | object | Y | 按产品线分组的配额计划声明 | +| Tokens | object | Y | 按产品线分组的 api-key 声明 | +| Config | object | Y | 按产品线分组的 api-key 鉴权规则配置 | + +## 3. QuotaPlans 结构(配额计划定义) + +配额计划在顶层 `QuotaPlans` 中按产品线分组,Token 通过 `quota_plans` 数组引用这些计划的 ID。 + +```json +{ "QuotaPlans": { "example_product": [ { - "id": "daily_quota", - "unlimited": false, - "pass_no_quota": false, - "redis_key": "ai:quota:daily_quota", - "create_time": 1672531200, + "Id": "daily_quota", + "Unlimited": false, + "PassNoQuota": false, + "RedisKey": "ai:quota:daily_quota", + "ExpiredTime": -1, + "Quota": 100000, + "Unit": "total_token" + } + ] + } +} +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| Id | string | Y | 配额计划 ID | 非空字符串 | +| Unlimited | bool | Y | 是否无限配额 | - | +| PassNoQuota | bool | Y | 配额不足时是否放行;为 `true` 时跳过该计划的余额检查 | - | +| RedisKey | string | N | Redis 中存储配额余额的 Key | `Unlimited` 为 `false` 时必须有有效值,否则运行时扣减/校验余额会失败 | +| ExpiredTime | int64 | N | 过期时间;`-1` 表示永不过期 | 必须大于等于 `-1` | +| Quota | int64 | N | 配额总量 | `Unit=total_token` 且 `Unlimited=false` 时必须大于 0;`Unit=RMB` 且 `Unlimited=false` 时必须大于等于 0 | +| Unit | string | N | 配额单位 | `total_token` 或 `RMB`;为空时默认 `total_token` | + +## 4. Tokens 结构(api-key 声明) + +api-key 在顶层 `Tokens` 中按产品线分组,外层 key 为 api-key 值。 + +```json +{ + "Tokens": { + "example_product": { + "TESTKEY": { + "key": "TESTKEY", + "key_id": "test_key_id", + "enabled": true, "expired_time": -1, - "quota": 100000, - "reset_mode": 1, - "unit": "total_token" + "unlimited_quota": false, + "allow_models": "model_a,model_b", + "block_models": "model_c", + "subnet": "192.168.0.0/24", + "Tags": [ + {"TagName": "department", "TagValue": "engineering", "TagLevel": 3} + ], + "quota_plans": ["daily_quota"] + } + } + } +} +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| key | string | Y | api-key 值 | - | +| key_id | string | Y | api-key 标识 ID | 非空字符串 | +| enabled | bool | Y | 是否启用 | `true` 启用,`false` 禁用 | +| expired_time | int64 | N | 过期时间;`-1` 表示永不过期 | 必须大于等于 `-1` | +| unlimited_quota | bool | Y | 是否无限配额 | - | +| allow_models | string | N | 允许的模型列表 | 多个模型名以逗号分隔;不能包含空字符串;空或 `""` 表示不限制 | +| block_models | string | N | 禁止的模型列表 | 多个模型名以逗号分隔;不能包含空字符串;空或 `""` 表示无禁止模型 | +| subnet | string | N | 允许的源 IP 子网 | 多个子网以逗号分隔;须为有效的 CIDR 格式;空或 `""` 表示不限制 | +| Tags | []ApikeyTag | N | api-key 标签列表 | JSON 字段名为 `Tags`;因结构体未设置 json tag,标签项字段名为 `TagName`/`TagValue`(大小写不敏感) | +| quota_plans | []string | N | 关联的配额计划 ID 列表 | `unlimited_quota` 为 `false` 时必填非空;引用的 ID 必须在同产品线的 `QuotaPlans` 中已定义 | + +### 4.1 ApikeyTag 结构 + +| 字段 | 类型 | 必填 | 说明 | 示例 | +|------|------|------|------|------| +| TagName | string | N | 标签名 | `department` | +| TagValue | string | N | 标签值 | `engineering` | +| TagLevel | int | N | 标签级别,取值为 1~5 的整数 | `3` | + +## 5. Config 结构(api-key 鉴权规则) + +```json +{ + "Config": { + "example_product": [ + { + "Cond": "default_t()", + "Action": { + "Cmd": "CHECK_TOKEN" + } + } + ] + } +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| Cond | string | Y | 路由匹配条件表达式,语法详见 [Condition](../../condition/condition_grammar.md) | +| Action | object | Y | 匹配后执行的动作 | +| Action.Cmd | string | Y | 动作命令,固定为 `CHECK_TOKEN` | + +## 6. 完整配置示例 + +```json +{ + "Version": "20190101000000", + "QuotaPlans": { + "example_product": [ + { + "Id": "daily_quota", + "Unlimited": false, + "PassNoQuota": false, + "RedisKey": "ai:quota:daily_quota", + "ExpiredTime": -1, + "Quota": 100000, + "Unit": "total_token" }, { - "id": "daily_rmb_quota", - "unlimited": false, - "pass_no_quota": false, - "redis_key": "ai:quota:daily_rmb_quota", - "create_time": 1672531200, - "expired_time": -1, - "quota": 90000000, - "reset_mode": 0, - "unit": "RMB" + "Id": "daily_rmb_quota", + "Unlimited": false, + "PassNoQuota": false, + "RedisKey": "ai:quota:daily_rmb_quota", + "ExpiredTime": -1, + "Quota": 90000000, + "Unit": "RMB" } ] }, @@ -86,14 +157,14 @@ "TESTKEY": { "key": "TESTKEY", "key_id": "test_key_id", - "status": 1, + "enabled": true, "expired_time": -1, "unlimited_quota": false, "allow_models": "model_a,model_b", "block_models": "model_c", "subnet": "192.168.0.0/24", - "tags": [ - {"key": "department", "value": "engineering"} + "Tags": [ + {"TagName": "department", "TagValue": "engineering", "TagLevel": 3} ], "quota_plans": ["daily_quota"] } @@ -102,9 +173,9 @@ "Config": { "example_product": [ { - "cond": "default_t()", - "action": { - "cmd": "CHECK_TOKEN" + "Cond": "default_t()", + "Action": { + "Cmd": "CHECK_TOKEN" } } ] @@ -112,6 +183,8 @@ } ``` -> 说明: -> - `unit = total_token` 时,`quota` 为整数 Token 数。 -> - `unit = RMB` 时,`quota` 为定点整数,精度为 `1e-8` 元(即 1 单位 = 0.00000001 元)。例如 `90000000` 表示 `0.9` 元。 +## 7. 说明 + +- `Unit = total_token` 时,`Quota` 为整数 Token 数。 +- `Unit = RMB` 时,`Quota` 为定点整数,精度为 `1e-8` 元(即 1 单位 = 0.00000001 元)。例如 `90000000` 表示 `0.9` 元。 +- BFE 使用 Go 的 `encoding/json` 解析配置文件,字段匹配不区分大小写;但 `QuotaPlan`、`Cond`、`Action`、`Cmd`、`Tags` 等结构体未设置 json tag,因此示例中保留与 Go 字段同名的 CamelCase 写法。若使用 snake_case,部分字段(如 `PassNoQuota`、`RedisKey`、`ExpiredTime`)可能无法被正确解析。 diff --git a/docs/zh_cn/modifications/2026-08-21-token-rule-data-field-simplification/design-changes.md b/docs/zh_cn/modifications/2026-08-21-token-rule-data-field-simplification/design-changes.md new file mode 100644 index 000000000..f9144d3d1 --- /dev/null +++ b/docs/zh_cn/modifications/2026-08-21-token-rule-data-field-simplification/design-changes.md @@ -0,0 +1,511 @@ +# 简化 `token_rule.data` 配置字段 + +## 1. 背景 + +`bfe/docs/zh_cn/configuration/mod_ai_token_auth/token_rule.data.md` 已完成字段精简: + +- **QuotaPlan**:删除 `CreateTime`、`ResetMode`。 +- **Token**:删除 `status`、`update_time`,由 `enabled`/`expired_time` 表达启用与过期状态。 + +当前 BFE 代码中这些字段仍然存在,且 `status` 在 `mod_ai_token_auth` 的认证逻辑中被实际使用。为使代码与文档保持一致,需要同步清理相关代码,并将 `status` 承载的状态判断能力迁移到 `enabled` 与 `expired_time`。 + +--- + +## 2. 目标 + +1. 从 `QuotaPlan` 和 `Token` 数据结构中删除已废弃字段。 +2. 从配置校验、测试代码、测试数据中删除已废弃字段。 +3. 将 Token 状态判断(禁用、过期、配额耗尽)从 `status` 迁移到 `enabled`/`expired_time`/实时配额检查。 +4. 保持现有认证、配额检查行为不变。 +5. 更新相关设计文档与测试设计文档。 + +--- + +## 3. 变更总览 + +| 层级 | 变更点 | 影响文件 | +|---|---|---| +| 数据结构 | 删除 `QuotaPlan.CreateTime`、`QuotaPlan.ResetMode`、`Token.Status`、`Token.UpdateTime`、`TokenFile.Status`、`TokenFile.UpdateTime`;`Token` 新增 `Enabled` | `bfe/bfe_modules/mod_ai_token_auth/token.go` | +| 配置校验 | 删除 `ResetMode` 与 `Status` 校验 | `bfe/bfe_modules/mod_ai_token_auth/token.go`、`token_rule_load.go` | +| 认证逻辑 | `ValidateUserToken`/`ValidateUserTokenByReq` 用 `enabled`/`expired_time` 替代 `status` | `bfe/bfe_modules/mod_ai_token_auth/token_rule_table.go` | +| 状态常量 | 删除 `TokenStatus*` 常量或仅保留内部使用 | `bfe/bfe_modules/mod_ai_token_auth/token.go` | +| 标签结构 | `ApikeyTag` 新增 `TagLevel` 字段 | `bfe/bfe_basic/request_ai_basic.go` | +| 单元测试 | 删除/调整涉及 `Status`、`UpdateTime`、`CreateTime`、`ResetMode` 的用例 | `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go` | +| 测试数据 | 删除测试配置文件中的废弃字段 | `bfe/bfe_modules/mod_ai_token_auth/testdata/mod_ai_token_auth/token_rule.data` | +| 集成测试公共库 | 删除 `BFEConfigBuilder` 中的废弃字段 | `bfe/tests/integration/common/bfe_config_builder.go` | +| 集成测试实现 | 删除 SC03/SC05 测试中的废弃字段 | `bfe/tests/integration/implementation/scenario-SC03-rmb-quota/...`、`scenario-SC05-access-log-ai-fields/...` | +| 设计文档 | 更新 `rmb_quota.md` 中的 `QuotaPlan` 结构说明 | `bfe/docs/zh_cn/sys_design/rmb_quota.md` | +| 测试设计文档 | 删除场景说明中的废弃字段 | `bfe/tests/integration/测试设计文档/...` | + +--- + +## 4. 详细设计 + +### 4.1 QuotaPlan 字段删除 + +当前 `QuotaPlan` 中 `CreateTime` 与 `ResetMode` 仅被解析和校验,`ResetMode` 未参与任何配额扣减逻辑,`CreateTime` 完全未被读取。 + +**删除后结构(`bfe/bfe_modules/mod_ai_token_auth/token.go`)** + +```go +type QuotaPlan struct { + Id string + Unlimited bool + PassNoQuota bool + RedisKey string + ExpiredTime int64 // -1 means never expired + Quota int64 // 配额总量,固定点整数:total_token 时为 Token 数;RMB 时为 1e-8 元 + Unit string // "total_token" or "RMB" +} +``` + +**配置校验(`bfe/bfe_modules/mod_ai_token_auth/token_rule_load.go`)** + +删除 `quotaPlanCheck` 中的 `ResetMode` 校验: + +```go +// 删除以下代码 +if conf.ResetMode < 0 || conf.ResetMode > 1 { + return fmt.Errorf("invalid ResetMode: %d", conf.ResetMode) +} +``` + +### 4.2 Token 字段删除与状态迁移 + +当前 Token 状态由 `status` 字段表达,取值: + +| 状态码 | 含义 | +|--------|------| +| 1 | 启用 | +| 2 | 禁用 | +| 3 | 过期 | +| 4 | 配额耗尽 | + +删除 `status` 后,需迁移为: + +| 原状态 | 新判断方式 | 说明 | +|--------|-----------|------| +| 禁用(2) | `!enabled` | `enabled` 字段已存在,表达是否启用 | +| 过期(3) | `expired_time != -1 && expired_time < now` | 与当前 `expired_time` 判断一致 | +| 配额耗尽(4) | 认证阶段实时检查 Redis 配额余额 | 当前代码已在 `ValidateUserTokenByReq` 中执行 `plan.HasBalance`,可完全替代 | + +**删除后结构(`bfe/bfe_modules/mod_ai_token_auth/token.go`)** + +```go +type Token struct { + Key string + KeyId string + Enabled bool + ExpiredTime int64 + UnlimitedQuota bool + Models []string + BlockModels []string + Subnet []*net.IPNet + Tags []bfe_basic.ApikeyTag + QuotaPlans []*QuotaPlan +} +``` + +```go +type TokenFile struct { + Key string `json:"key"` + KeyId string `json:"key_id"` + Enabled bool `json:"enabled"` + ExpiredTime int64 `json:"expired_time"` // -1 means never expired + UnlimitedQuota bool `json:"unlimited_quota"` + Models *string `json:"allow_models"` + BlockModels *string `json:"block_models"` + Subnet *string `json:"subnet"` + Tags []bfe_basic.ApikeyTag + QuotaPlans []string `json:"quota_plans"` + models []string + blockModels []string + subnet []*net.IPNet +} +``` + +**配置校验(`bfe/bfe_modules/mod_ai_token_auth/token.go`)** + +删除 `tokenCheck` 中的 `Status` 校验: + +```go +// 删除以下代码 +if conf.Status < TokenStatusEnabled || conf.Status > TokenStatusExhausted { + return fmt.Errorf("invalid Status: %d", conf.Status) +} +``` + +`tokenConvert` 需将 `Enabled` 从 `TokenFile` 复制到 `Token`: + +```go +return Token{ + Key: tokenFile.Key, + KeyId: tokenFile.KeyId, + Enabled: tokenFile.Enabled, + ExpiredTime: tokenFile.ExpiredTime, + UnlimitedQuota: tokenFile.UnlimitedQuota, + Models: tokenFile.models, + BlockModels: tokenFile.blockModels, + Subnet: tokenFile.subnet, + Tags: tokenFile.Tags, + QuotaPlans: quotaPlans, +}, nil +``` + + + +### 4.3 认证逻辑改造 + +`bfe/bfe_modules/mod_ai_token_auth/token_rule_table.go` 中 `ValidateUserToken` 与 `ValidateUserTokenByReq` 当前通过 `token.Status` 判断状态。 + +#### 4.3.1 `ValidateUserToken` 改造 + +当前逻辑: + +```go +switch token.Status { +case TokenStatusExhausted: + return nil, fmt.Errorf("token %s quota exhausted", token.KeyId) +case TokenStatusExpired: + return nil, fmt.Errorf("token %s expired", token.KeyId) +case TokenStatusDisabled: + return nil, fmt.Errorf("token %s disabled", token.KeyId) +} + +if token.ExpiredTime != -1 && token.ExpiredTime < time.Now().Unix() { + token.Status = TokenStatusExpired + return nil, fmt.Errorf("token %s expired", token.KeyId) +} +``` + +改造后: + +```go +if !token.Enabled { + return nil, fmt.Errorf("token %s disabled", token.KeyId) +} + +if token.ExpiredTime != -1 && token.ExpiredTime < time.Now().Unix() { + return nil, fmt.Errorf("token %s expired", token.KeyId) +} +``` + +> `TokenStatusExhausted` 不再通过配置表达,认证阶段会在后续 `QuotaPlans` 余额检查中统一处理。 + +#### 4.3.2 `ValidateUserTokenByReq` 改造 + +当前逻辑: + +```go +switch token.Status { +case TokenStatusExhausted: + SetApiAuthInfo(req, bfe_basic.CodeInvalidApiKey, nil) + return nil, bfe_basic.NewAiErrorWithDetails(...) +case TokenStatusExpired: + ... +case TokenStatusDisabled: + ... +} + +if token.ExpiredTime != -1 && token.ExpiredTime < time.Now().Unix() { + token.Status = TokenStatusExpired + ... +} +``` + +改造后: + +```go +if !token.Enabled { + SetApiAuthInfo(req, bfe_basic.CodeKeyDisabled, nil) + return nil, bfe_basic.NewAiErrorWithDetails( + bfe_basic.CodeKeyDisabled, + bfe_basic.TypeAuthenticationError, + fmt.Sprintf("Invalid API key: %s. disabled.", key), + &bfe_basic.AiErrorDetail{ApiKey: key, KeyId: token.KeyId}, + ) +} + +if token.ExpiredTime != -1 && token.ExpiredTime < time.Now().Unix() { + return nil, bfe_basic.NewAiErrorWithDetails( + bfe_basic.CodeKeyExpired, + ..., + ) +} +``` + +### 4.4 状态常量处理 + +`bfe/bfe_modules/mod_ai_token_auth/token.go` 中当前定义: + +```go +const ( + TokenStatusEnabled = 1 + TokenStatusDisabled = 2 + TokenStatusExpired = 3 + TokenStatusExhausted = 4 +) +``` + +由于 `status` 字段删除,这些常量应同步删除。若其他模块通过包引用使用了这些常量,需一并清理。 + +### 4.5 ApikeyTag 结构增加 TagLevel + +`token_rule.data.md` 4.1 节已新增 `TagLevel` 字段,用于表达标签级别(1~5)。需要在 `bfe_basic.ApikeyTag` 结构中添加对应字段: + +**当前结构(`bfe/bfe_basic/request_ai_basic.go`)** + +```go +type ApikeyTag struct { + TagName string //eg entity.type + TagValue string //eg entity.name +} +``` + +**改造后结构** + +```go +type ApikeyTag struct { + TagName string //eg entity.type + TagValue string //eg entity.name + TagLevel int // 标签级别,取值为 1~5 的整数 +} +``` + +> `TagLevel` 当前版本仅作为保留/透传字段,BFE 认证与配额逻辑可不依赖其取值。但结构定义需与配置文档保持一致,以便 JSON 反序列化正确填充。 + +#### 4.5.1 访问日志序列化(依赖 `bfe-access-pb`) + +`bfe/bfe_modules/mod_access_pb3/request_log.go` 将 `AiBasicInfo.ApikeyTags` 序列化为访问日志 protobuf。`bfe-access-pb/bfe_access_pb/bfe_access.proto` 中的 `ApikeyTag` 消息需要同步新增 `tag_level` 字段: + +```protobuf +message ApikeyTag { + optional string tagname = 1; + optional string tagvalue = 2; + optional int32 taglevel = 3; // 新增 +} +``` + +然后执行 `bfe-access-pb/build.sh` 重新生成 `bfe_access.pb.go`。 + +生成后,`request_log.go` 中序列化逻辑需补充 `TagLevel`: + +```go +reqLog.AiApikeytags = append(reqLog.AiApikeytags, &bfe_access_pb3.ApikeyTag{ + Tagname: proto.String(tag.TagName), + Tagvalue: proto.String(tag.TagValue), + Taglevel: proto.Int32(int32(tag.TagLevel)), +}) +``` + +> 注:`bfe-access-pb/bfe_access.pb.go` 已通过 `build.sh` 重新生成,`request_log.go` 已补充 `TagLevel` 序列化。 + +--- + +## 5. 关键代码变更示例 + +### 5.1 `token.go` + +```go +type Token struct { + Key string + KeyId string + Enabled bool + ExpiredTime int64 + UnlimitedQuota bool + Models []string + BlockModels []string + Subnet []*net.IPNet + Tags []bfe_basic.ApikeyTag + QuotaPlans []*QuotaPlan +} + +type TokenFile struct { + Key string `json:"key"` + KeyId string `json:"key_id"` + Enabled bool `json:"enabled"` + ExpiredTime int64 `json:"expired_time"` + UnlimitedQuota bool `json:"unlimited_quota"` + Models *string `json:"allow_models"` + BlockModels *string `json:"block_models"` + Subnet *string `json:"subnet"` + Tags []bfe_basic.ApikeyTag + QuotaPlans []string `json:"quota_plans"` + models []string + blockModels []string + subnet []*net.IPNet +} + +type QuotaPlan struct { + Id string + Unlimited bool + PassNoQuota bool + RedisKey string + ExpiredTime int64 + Quota int64 + Unit string +} +``` + +### 5.2 `token_rule_table.go` 中的 `ValidateUserTokenByReq` + +```go +if !token.Enabled { + SetApiAuthInfo(req, bfe_basic.CodeKeyDisabled, nil) + return nil, bfe_basic.NewAiErrorWithDetails( + bfe_basic.CodeKeyDisabled, + bfe_basic.TypeAuthenticationError, + fmt.Sprintf("Invalid API key: %s. disabled.", key), + &bfe_basic.AiErrorDetail{ApiKey: key, KeyId: token.KeyId}, + ) +} + +if token.ExpiredTime != -1 && token.ExpiredTime < time.Now().Unix() { + SetApiAuthInfo(req, bfe_basic.CodeKeyExpired, nil) + return nil, bfe_basic.NewAiErrorWithDetails( + bfe_basic.CodeKeyExpired, + bfe_basic.TypeAuthenticationError, + fmt.Sprintf("Invalid API key: %s. expired.", key), + &bfe_basic.AiErrorDetail{ApiKey: key, KeyId: token.KeyId}, + ) +} +``` + +### 5.3 `token_rule_load.go` + +```go +func quotaPlanCheck(conf *QuotaPlan) error { + if conf.Id == "" { + return errors.New("no Id") + } + if conf.ExpiredTime < -1 { + return fmt.Errorf("invalid ExpiredTime: %d", conf.ExpiredTime) + } + + if conf.Unit == "" { + conf.Unit = quota.UnitTotalToken + } + if conf.Unit != quota.UnitTotalToken && conf.Unit != quota.UnitRMB { + return fmt.Errorf("invalid Unit: %s", conf.Unit) + } + + if !conf.Unlimited { + if conf.Unit == quota.UnitRMB { + if conf.Quota < 0 { + return fmt.Errorf("invalid Quota for RMB: %d", conf.Quota) + } + } else { + if conf.Quota <= 0 { + return fmt.Errorf("invalid Quota: %d", conf.Quota) + } + } + } + + return nil +} +``` + +--- + +## 6. 测试计划 + +### 6.1 单元测试 + +修改 `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go`: + +1. 删除/调整 `TestQuotaPlanCheck` 中 `ResetMode` 相关用例。 +2. 删除/调整 `TestTokenCheck` 中 `Status` 相关用例,新增 `Enabled` 非法值用例。 +3. 删除 `Token` 测试构造中的 `Status`、`UpdateTime` 字段。 +4. 调整 `ValidateUserToken`/`ValidateUserTokenByReq` 测试: + - 禁用场景:构造 `Enabled = false` 的 Token,期望 `CodeKeyDisabled`。 + - 过期场景:构造 `expired_time < now` 的 Token,期望 `CodeKeyExpired`。 + - 配额耗尽场景:保持现有 `plan.HasBalance` 相关测试,确认仍返回 `CodeQuotaExhausted`。 + +### 6.2 集成测试 + +1. 更新 `bfe/tests/integration/common/bfe_config_builder.go` 中的 `QuotaPlan` 与 `Token` 结构定义。 +2. 更新 SC03、SC05 测试代码中的 Token/QuotaPlan 构造。 +3. 更新 `bfe/bfe_modules/mod_ai_token_auth/testdata/mod_ai_token_auth/token_rule.data` 测试数据。 +4. 全量运行: + +```bash +cd bfe +go test ./bfe_modules/mod_ai_token_auth/... +go test ./tests/integration/... +``` + +### 6.3 回归测试 + +- `go test ./bfe_modules/mod_ai_token_auth/...` +- `go test ./tests/integration/...` +- 针对禁用、过期、配额耗尽三种拒绝场景的端到端验证。 + +--- + +## 7. 文档更新 + +1. **`bfe/docs/zh_cn/configuration/mod_ai_token_auth/token_rule.data.md`** + - 已完成字段精简。 + +2. **`bfe/docs/zh_cn/sys_design/rmb_quota.md`** + - 删除 6.2 节 `QuotaPlan` 结构说明中的 `CreateTime`、`ResetMode`。 + +3. **`bfe/tests/integration/测试设计文档/scenario-SC03-RMB配额扣减/场景说明.md`** + - 删除示例配置中的 `CreateTime`、`ResetMode`。 + +4. **`bfe/tests/integration/测试设计文档/scenario-SC05-AI访问日志字段校验/场景说明.md`** + - 删除示例配置中的 `CreateTime`、`ResetMode`。 + +5. **`bfe/docs/zh_cn/modifications/2026-08-21-token-rule-data-field-simplification/design-changes.md`** + - 本设计变更文档(即本文档)。 + +--- + +## 8. 影响范围 + +| 模块/文件 | 影响 | +|-----------|------| +| `bfe/bfe_modules/mod_ai_token_auth/token.go` | 删除字段、调整 `tokenCheck` | +| `bfe/bfe_modules/mod_ai_token_auth/token_rule_load.go` | 删除 `ResetMode` 校验 | +| `bfe/bfe_modules/mod_ai_token_auth/token_rule_table.go` | 状态判断逻辑迁移 | +| `bfe/bfe_basic/request_ai_basic.go` | `ApikeyTag` 新增 `TagLevel` | +| `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go` | 单元测试调整 | +| `bfe/bfe_modules/mod_ai_token_auth/testdata/...` | 测试数据更新 | +| `bfe/tests/integration/common/bfe_config_builder.go` | 公共结构定义更新 | +| `bfe/tests/integration/implementation/scenario-SC03/SC05/...` | 集成测试更新 | +| `bfe/docs/zh_cn/sys_design/rmb_quota.md` | 设计文档更新 | +| `bfe/tests/integration/测试设计文档/...` | 测试设计文档更新 | + +--- + +## 9. 兼容性与风险 + +### 9.1 兼容性 + +- **配置格式变更**:`token_rule.data` 不再接受 `status`、`update_time`、`CreateTime`、`ResetMode`。旧配置若继续包含这些字段,Go 的 `encoding/json` 会忽略未知字段(不报错),但实际逻辑不再依赖它们。 +- **外部系统**:ai-gateway-api 等导出系统需要同步停止导出 `status`、`update_time`、`CreateTime`、`ResetMode`。 +- **Redis/配额行为**:删除 `ResetMode` 不影响当前配额扣减,因为该字段从未参与运行时逻辑。 + +### 9.2 风险与缓解 + +| 风险 | 缓解措施 | +|------|---------| +| `status` 删除后,外部系统依赖 `status=4` 表达配额耗尽 | 改为由 BFE 认证阶段实时检查 Redis 余额;外部系统可在配额耗尽时设置 `enabled=false` 禁用 Key | +| `enabled` 字段语义变化 | 文档明确 `enabled=true` 启用,`false` 禁用 | +| 状态码常量删除影响其他模块 | 全局搜索 `TokenStatus*` 引用,同步清理 | +| 集成测试配置未同步更新 | 全量跑 `bfe/tests/integration/...` 验证 | + +**回滚**:若发现兼容性问题,可回滚代码与文档。本次变更不涉及 Redis key 结构或网络协议变更,回滚影响面可控。 + +--- + +## 10. 关键代码索引 + +| 文件 | 行号范围 | 说明 | +|---|---|---| +| `bfe/bfe_modules/mod_ai_token_auth/token.go` | 41-83 | `Token`、`TokenFile`、`QuotaPlan` 结构定义 | +| `bfe/bfe_modules/mod_ai_token_auth/token.go` | 177-224 | `tokenCheck` 校验逻辑 | +| `bfe/bfe_modules/mod_ai_token_auth/token_rule_load.go` | 119-151 | `quotaPlanCheck` 校验逻辑 | +| `bfe/bfe_modules/mod_ai_token_auth/token_rule_table.go` | 71-96 | `ValidateUserToken` 状态判断 | +| `bfe/bfe_modules/mod_ai_token_auth/token_rule_table.go` | 106-167 | `ValidateUserTokenByReq` 状态判断 | +| `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go` | 340-450 | Token/QuotaPlan 单元测试 | diff --git a/docs/zh_cn/sys_design/rmb_quota.md b/docs/zh_cn/sys_design/rmb_quota.md index 368a0bdf5..38da90330 100644 --- a/docs/zh_cn/sys_design/rmb_quota.md +++ b/docs/zh_cn/sys_design/rmb_quota.md @@ -201,11 +201,9 @@ type QuotaPlan struct { Unlimited bool PassNoQuota bool RedisKey string - CreateTime int64 ExpiredTime int64 Quota int64 // 固定点整数:total_token 时为 Token 数;RMB 时为 1e-8 元 - ResetMode int - Unit string // 新增:"total_token" 或 "RMB" + Unit string // "total_token" 或 "RMB" } ``` diff --git a/go.mod b/go.mod index 567791bf8..b82a7d98a 100644 --- a/go.mod +++ b/go.mod @@ -70,7 +70,7 @@ require ( require ( github.com/HdrHistogram/hdrhistogram-go v1.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/bfenetworks/bfe-access-pb v0.2.0 + github.com/bfenetworks/bfe-access-pb v0.3.0 github.com/bfenetworks/bfe-mock-waf v0.1.0 github.com/bfenetworks/bwi v0.1.2 github.com/davecgh/go-spew v1.1.1 // indirect @@ -96,4 +96,3 @@ require ( ) // replace github.com/bfenetworks/proxy-wasm-go-host => ../proxy-wasm-go-host -// replace github.com/bfenetworks/bfe-access-pb => ../bfe-access-pb diff --git a/go.sum b/go.sum index bcac9a999..eef1782b8 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bfenetworks/bfe-access-pb v0.2.0 h1:2JBUPJt2ZckqDUoL5F5cBhtqoY94MllIX3m4hJ3jVK4= -github.com/bfenetworks/bfe-access-pb v0.2.0/go.mod h1:3qunybhMV5hEeT/2qDwbchxo4t/9LPKEvwwZkmekRho= +github.com/bfenetworks/bfe-access-pb v0.3.0 h1:DXfFJ3RdBRTrsllFsGA0ElYjd6Whbj0JxDMV7NnbTj0= +github.com/bfenetworks/bfe-access-pb v0.3.0/go.mod h1:3qunybhMV5hEeT/2qDwbchxo4t/9LPKEvwwZkmekRho= github.com/bfenetworks/bfe-mock-waf v0.1.0 h1:dTd540S3nv6qNlG6lLC0F8qx3gyo5WB4wnci3+d4J78= github.com/bfenetworks/bfe-mock-waf v0.1.0/go.mod h1:MWZHbihiRQXpoUCvY1l18s2bfOBWx4N4pghxBt+xUv0= github.com/bfenetworks/bwi v0.1.2 h1:3AcCzUjyzKm+FeLgTIVg58u+SUdEVZdJbQM58Ezwjcg= diff --git a/tests/integration/common/bfe_config_builder.go b/tests/integration/common/bfe_config_builder.go index 4212c8a40..5489e2a75 100644 --- a/tests/integration/common/bfe_config_builder.go +++ b/tests/integration/common/bfe_config_builder.go @@ -40,10 +40,8 @@ type QuotaPlan struct { Unlimited bool PassNoQuota bool RedisKey string - CreateTime int64 ExpiredTime int64 Quota int64 - ResetMode int Unit string } @@ -51,9 +49,7 @@ type QuotaPlan struct { type TokenFile struct { Key string `json:"key"` KeyId string `json:"key_id"` - Enabled int `json:"enabled"` - Status int `json:"status"` - UpdateTime int64 `json:"update_time"` + Enabled bool `json:"enabled"` ExpiredTime int64 `json:"expired_time"` UnlimitedQuota bool `json:"unlimited_quota"` Models *string `json:"allow_models"` diff --git a/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go b/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go index 92e061487..e43c33758 100644 --- a/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go +++ b/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go @@ -95,9 +95,7 @@ func newTestEnv(t *testing.T, aiConfs map[string]*cluster_conf.AIConf, quotaPlan apiKey: { Key: apiKey, KeyId: apiKeyId, - Enabled: 1, - Status: 1, - UpdateTime: 0, + Enabled: true, ExpiredTime: -1, UnlimitedQuota: false, QuotaPlans: planIDs(quotaPlans), @@ -256,10 +254,8 @@ func rmbQuotaPlan(quota int64) common.QuotaPlan { Unlimited: false, PassNoQuota: false, RedisKey: redisKeyRMB, - CreateTime: 0, ExpiredTime: -1, Quota: quota, - ResetMode: 0, Unit: "RMB", } } @@ -270,10 +266,8 @@ func tokenQuotaPlan(quota int64) common.QuotaPlan { Unlimited: false, PassNoQuota: false, RedisKey: redisKeyToken, - CreateTime: 0, ExpiredTime: -1, Quota: quota, - ResetMode: 0, Unit: "total_token", } } diff --git a/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go b/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go index d5e8e3013..3e843b902 100644 --- a/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go +++ b/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go @@ -100,9 +100,7 @@ func newTestEnv(t *testing.T, aiConfs map[string]*cluster_conf.AIConf, quotaPlan apiKey: { Key: apiKey, KeyId: apiKeyId, - Enabled: 1, - Status: 1, - UpdateTime: 0, + Enabled: true, ExpiredTime: -1, UnlimitedQuota: false, QuotaPlans: planIDs(quotaPlans), @@ -294,10 +292,8 @@ func rmbQuotaPlan(quota int64) common.QuotaPlan { Unlimited: false, PassNoQuota: false, RedisKey: redisKeyRMB, - CreateTime: 0, ExpiredTime: -1, Quota: quota, - ResetMode: 0, Unit: "RMB", } } @@ -308,10 +304,8 @@ func tokenQuotaPlan(quota int64) common.QuotaPlan { Unlimited: false, PassNoQuota: false, RedisKey: redisKeyToken, - CreateTime: 0, ExpiredTime: -1, Quota: quota, - ResetMode: 0, Unit: "total_token", } } diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" index cb56a3d8e..94b4b0bae 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" @@ -148,10 +148,8 @@ BFE v0.4 在 `mod_ai_token_auth` 中引入 RMB(人民币)配额支持。与 "Unlimited": false, "PassNoQuota": false, "RedisKey": "quota:plan_rmb", - "CreateTime": 0, "ExpiredTime": -1, "Quota": 10000000000, - "ResetMode": 0, "Unit": "RMB", "Currency": "RMB" } @@ -167,10 +165,8 @@ BFE v0.4 在 `mod_ai_token_auth` 中引入 RMB(人民币)配额支持。与 "Unlimited": false, "PassNoQuota": false, "RedisKey": "quota:plan_token", - "CreateTime": 0, "ExpiredTime": -1, "Quota": 10000, - "ResetMode": 0, "Unit": "total_token" } ``` diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" index b94241e82..f8e82fb4e 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" @@ -147,10 +147,8 @@ "Unlimited": false, "PassNoQuota": false, "RedisKey": "quota:plan_rmb", - "CreateTime": 0, "ExpiredTime": -1, "Quota": 10000000000, - "ResetMode": 0, "Unit": "RMB" } ``` @@ -163,10 +161,8 @@ "Unlimited": false, "PassNoQuota": false, "RedisKey": "quota:plan_token", - "CreateTime": 0, "ExpiredTime": -1, "Quota": 10000, - "ResetMode": 0, "Unit": "total_token" } ``` From 0496eb85c850fb5d69e00edb3c87c891965f7eae Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Sat, 22 Aug 2026 08:48:00 +0800 Subject: [PATCH 02/18] feat(bfe): support cache token billing for AI requests - Extend TokenUsage with CacheReadTokens and CacheWriteTokens - Add cache price constants and fixed-point conversion in cluster_conf - Parse cache usage in non-streaming and streaming paths - Update calcCostUnits to split cache billing with fallback logic - Serialize cache fields in access log protobuf - Upgrade bfe-access-pb to v0.3.1 - Add unit tests and SC03 integration tests for cache billing - Update design docs and integration test design docs Signed-off-by: zhangmiao --- bfe_basic/request_ai_basic.go | 4 +- .../cluster_conf/cluster_conf_load.go | 19 +- .../cluster_conf/cluster_conf_load_test.go | 31 ++ bfe_modules/mod_access_pb3/request_log.go | 6 + .../mod_access_pb3/request_log_test.go | 8 + .../mod_ai_token_auth/mod_ai_token_auth.go | 51 ++- .../mod_ai_token_auth_test.go | 202 ++++++++- bfe_modules/mod_body_process/body_process.go | 13 +- .../mod_body_process/content_quota_usage.go | 4 + .../content_quota_usage_test.go | 29 ++ bfe_modules/mod_body_process/llm_util.go | 14 +- .../design-changes.md | 404 ++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- .../sc03_rmb_quota_test.go | 103 +++++ ...01\345\274\217\350\256\241\350\264\271.md" | 82 ++++ ...01\345\274\217\350\256\241\350\264\271.md" | 74 ++++ ...72\346\231\257\350\257\264\346\230\216.md" | 27 +- 18 files changed, 1057 insertions(+), 20 deletions(-) create mode 100644 docs/zh_cn/modifications/2026-08-22-cache-billing-support/design-changes.md create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-07-Cache\346\250\241\345\236\213\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-08-Cache\346\250\241\345\236\213\346\265\201\345\274\217\350\256\241\350\264\271.md" diff --git a/bfe_basic/request_ai_basic.go b/bfe_basic/request_ai_basic.go index f3b987d61..b65e3b397 100644 --- a/bfe_basic/request_ai_basic.go +++ b/bfe_basic/request_ai_basic.go @@ -32,8 +32,10 @@ const ( ) type TokenUsage struct { - PromptTokens int64 // number of tokens in the prompt + PromptTokens int64 // number of tokens in the prompt (includes cache_read_tokens) CompletionTokens int64 // number of tokens in the completion + CacheReadTokens int64 // usage.cache_read_tokens, already included in PromptTokens + CacheWriteTokens int64 // usage.cache_write_tokens, independent add-on item UsedQuota int64 // used quota for this request (unit=total_token) UsedCost int64 // used RMB cost for this request, 1 unit = 1e-8 yuan (unit=RMB) } diff --git a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go index 27b3b6c90..7f44dac7b 100644 --- a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go +++ b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go @@ -181,10 +181,15 @@ type AIConf struct { } const ( - PriceInputCostPerToken = "input_cost_per_token" - PriceOutputCostPerToken = "output_cost_per_token" - PriceInputCostPerTokenInt = "input_cost_per_token_int" - PriceOutputCostPerTokenInt = "output_cost_per_token_int" + PriceInputCostPerToken = "input_cost_per_token" + PriceOutputCostPerToken = "output_cost_per_token" + PriceCacheReadInputTokenCost = "cache_read_input_token_cost" + PriceCacheCreationInputTokenCost = "cache_creation_input_token_cost" + + PriceInputCostPerTokenInt = "input_cost_per_token_int" + PriceOutputCostPerTokenInt = "output_cost_per_token_int" + PriceCacheReadInputTokenCostInt = "cache_read_input_token_cost_int" + PriceCacheCreationInputTokenCostInt = "cache_creation_input_token_cost_int" ) func (conf *BackendHTTPS) GetProtocol() string { @@ -803,12 +808,16 @@ func ModelTableCheck(table *ModelTable) error { input := price.Prices[PriceInputCostPerToken] output := price.Prices[PriceOutputCostPerToken] - if input < 0 || output < 0 { + cacheRead := price.Prices[PriceCacheReadInputTokenCost] + cacheWrite := price.Prices[PriceCacheCreationInputTokenCost] + if input < 0 || output < 0 || cacheRead < 0 || cacheWrite < 0 { return fmt.Errorf("negative price for model %s", price.Model) } price.Prices[PriceInputCostPerTokenInt] = float64(quota.RmbToFixedPoint(input)) price.Prices[PriceOutputCostPerTokenInt] = float64(quota.RmbToFixedPoint(output)) + price.Prices[PriceCacheReadInputTokenCostInt] = float64(quota.RmbToFixedPoint(cacheRead)) + price.Prices[PriceCacheCreationInputTokenCostInt] = float64(quota.RmbToFixedPoint(cacheWrite)) if table.priceIndex[price.Model] == nil { table.priceIndex[price.Model] = make(map[string]*ModelPrice) diff --git a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go index e2113befc..6ea17f6ce 100644 --- a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go +++ b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go @@ -152,6 +152,37 @@ func TestModelTableCheck(t *testing.T) { } }) + t.Run("valid RMB table with cache prices", func(t *testing.T) { + table := &ModelTable{ + Currency: "RMB", + Models: []ModelPrice{ + { + Model: "claude-opus-4-6", + Mode: "chat", + Prices: map[string]float64{ + PriceInputCostPerToken: 0.000004525, + PriceOutputCostPerToken: 0.000022625, + PriceCacheReadInputTokenCost: 0.0000004525, + PriceCacheCreationInputTokenCost: 0.00000565625, + }, + }, + }, + } + if err := ModelTableCheck(table); err != nil { + t.Fatalf("ModelTableCheck failed: %v", err) + } + entry := LookupModelPrice(table, "claude-opus-4-6", "chat") + if entry == nil { + t.Fatal("LookupModelPrice should return entry") + } + if entry.Prices[PriceCacheReadInputTokenCostInt] != 45 { + t.Errorf("cache read cost int = %v, want 45", entry.Prices[PriceCacheReadInputTokenCostInt]) + } + if entry.Prices[PriceCacheCreationInputTokenCostInt] != 565 { + t.Errorf("cache write cost int = %v, want 565", entry.Prices[PriceCacheCreationInputTokenCostInt]) + } + }) + t.Run("invalid currency", func(t *testing.T) { table := &ModelTable{ Currency: "USD", diff --git a/bfe_modules/mod_access_pb3/request_log.go b/bfe_modules/mod_access_pb3/request_log.go index 8dfec8845..fd8d54842 100644 --- a/bfe_modules/mod_access_pb3/request_log.go +++ b/bfe_modules/mod_access_pb3/request_log.go @@ -409,6 +409,12 @@ func reqAiInfoGen(reqLog *bfe_access_pb3.RequestLog, req *bfe_basic.Request, res reqLog.AiInputTokens = proto.Int64(usage.PromptTokens) reqLog.AiOutputTokens = proto.Int64(usage.CompletionTokens) reqLog.AiTotalTokens = proto.Int64(usage.UsedQuota) + if usage.CacheReadTokens > 0 { + reqLog.AiCacheReadTokens = proto.Int64(usage.CacheReadTokens) + } + if usage.CacheWriteTokens > 0 { + reqLog.AiCacheWriteTokens = proto.Int64(usage.CacheWriteTokens) + } if usage.UsedCost > 0 { reqLog.AiCostValue = proto.Int64(usage.UsedCost) } diff --git a/bfe_modules/mod_access_pb3/request_log_test.go b/bfe_modules/mod_access_pb3/request_log_test.go index f75b79017..eca5e1d62 100644 --- a/bfe_modules/mod_access_pb3/request_log_test.go +++ b/bfe_modules/mod_access_pb3/request_log_test.go @@ -374,6 +374,8 @@ func TestReqAiInfoGen(t *testing.T) { usage.PromptTokens = 10 usage.CompletionTokens = 20 usage.UsedQuota = 30 + usage.CacheReadTokens = 5 + usage.CacheWriteTokens = 2 usage.UsedCost = 5000 req.SetContext(bfe_basic.REQ_AI_BASIC_CONTEXT, aiInfo) @@ -416,6 +418,12 @@ func TestReqAiInfoGen(t *testing.T) { if reqLog.AiInputTokens == nil || *reqLog.AiInputTokens != 10 { t.Error("AiInputTokens error") } + if reqLog.AiCacheReadTokens == nil || *reqLog.AiCacheReadTokens != 5 { + t.Errorf("AiCacheReadTokens error, got: %v", reqLog.AiCacheReadTokens) + } + if reqLog.AiCacheWriteTokens == nil || *reqLog.AiCacheWriteTokens != 2 { + t.Errorf("AiCacheWriteTokens error, got: %v", reqLog.AiCacheWriteTokens) + } if reqLog.AiCostValue == nil || *reqLog.AiCostValue != 5000 { t.Error("AiCostValue error") } diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go index e51bad460..fb5b44c15 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go @@ -112,21 +112,27 @@ func (m *ModuleAITokenAuth) matchTokenRule(req *bfe_basic.Request) bool { } func UpdateCtxByUsage(ctx *TokenAuthContext, data []byte) { - var used, prompt, completion int64 + var used, prompt, completion, cacheRead, cacheWrite int64 used = gjson.GetBytes(data, "usage.total_tokens").Int() prompt = gjson.GetBytes(data, "usage.prompt_tokens").Int() completion = gjson.GetBytes(data, "usage.completion_tokens").Int() + cacheRead = gjson.GetBytes(data, "usage.cache_read_tokens").Int() + cacheWrite = gjson.GetBytes(data, "usage.cache_write_tokens").Int() tokenUsage := ctx.aiBasicInfo.GetTokenUsage() if used > 0 { tokenUsage.UsedQuota = used tokenUsage.PromptTokens = prompt tokenUsage.CompletionTokens = completion + tokenUsage.CacheReadTokens = cacheRead + tokenUsage.CacheWriteTokens = cacheWrite } else if prompt > 0 || completion > 0 { tokenUsage.UsedQuota = prompt + completion tokenUsage.PromptTokens = prompt tokenUsage.CompletionTokens = completion + tokenUsage.CacheReadTokens = cacheRead + tokenUsage.CacheWriteTokens = cacheWrite } } @@ -177,7 +183,7 @@ func (m *ModuleAITokenAuth) tokenRequestFinishHandler(req *bfe_basic.Request, re // calculate RMB cost at request finish time using token usage already populated // by mod_body_process (streaming) or tokenReadResponseHandler (non-streaming). if tokenUsage.UsedCost <= 0 && hasRMBPlan(ctx.Token.QuotaPlans) { - tokenUsage.UsedCost = m.calcCostUnits(req, ctx.serverConf, tokenUsage.PromptTokens, tokenUsage.CompletionTokens) + tokenUsage.UsedCost = m.calcCostUnits(req, ctx.serverConf, tokenUsage) } costUnits := tokenUsage.UsedCost @@ -410,9 +416,9 @@ func hasRMBPlan(plans []*QuotaPlan) bool { return false } -func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe_basic.ServerDataConfInterface, promptTokens, completionTokens int64) int64 { +func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe_basic.ServerDataConfInterface, usage *bfe_basic.TokenUsage) int64 { aiMeta := req.GetAiBasicInfo() - if aiMeta == nil { + if aiMeta == nil || usage == nil { return 0 } @@ -444,5 +450,40 @@ func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe return 0 } - return promptTokens*inputCost + completionTokens*outputCost + promptTokens := usage.PromptTokens + completionTokens := usage.CompletionTokens + cacheReadTokens := usage.CacheReadTokens + cacheWriteTokens := usage.CacheWriteTokens + + // sanitize cache usage to avoid negative normal input or negative cache charges + if cacheReadTokens < 0 { + cacheReadTokens = 0 + } + if cacheReadTokens > promptTokens { + cacheReadTokens = promptTokens + } + if cacheWriteTokens < 0 { + cacheWriteTokens = 0 + } + + cacheReadCost := int64(entry.Prices[cluster_conf.PriceCacheReadInputTokenCostInt]) + cacheWriteCost := int64(entry.Prices[cluster_conf.PriceCacheCreationInputTokenCostInt]) + + var cost int64 + if cacheReadCost > 0 || cacheWriteCost > 0 { + // cache-aware billing: split prompt into normal input and cache read + normalInput := promptTokens - cacheReadTokens + if normalInput < 0 { + normalInput = 0 + } + cost = normalInput*inputCost + + cacheReadTokens*cacheReadCost + + cacheWriteTokens*cacheWriteCost + + completionTokens*outputCost + } else { + // fallback to legacy billing when no cache price is configured + cost = promptTokens*inputCost + completionTokens*outputCost + } + + return cost } diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go index 805dd487b..2b7161481 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go @@ -320,6 +320,34 @@ func TestUpdateCtxByUsage(t *testing.T) { } } +func TestUpdateCtxByUsage_Cache(t *testing.T) { + req := newTestRequest("", "AI_product") + ai := req.InitAiBasicInfo() + ctx := &TokenAuthContext{aiBasicInfo: ai} + + UpdateCtxByUsage(ctx, []byte(`{"usage":{"total_tokens":12,"prompt_tokens":8,"completion_tokens":4,"cache_read_tokens":5,"cache_write_tokens":2}}`)) + usage := ai.GetTokenUsage() + if usage.UsedQuota != 12 || usage.PromptTokens != 8 || usage.CompletionTokens != 4 { + t.Errorf("unexpected base usage: %+v", usage) + } + if usage.CacheReadTokens != 5 { + t.Errorf("expected CacheReadTokens 5, got %d", usage.CacheReadTokens) + } + if usage.CacheWriteTokens != 2 { + t.Errorf("expected CacheWriteTokens 2, got %d", usage.CacheWriteTokens) + } + + ctx2 := &TokenAuthContext{aiBasicInfo: ai} + UpdateCtxByUsage(ctx2, []byte(`{"usage":{"prompt_tokens":3,"completion_tokens":1,"cache_read_tokens":1,"cache_write_tokens":1}}`)) + usage = ai.GetTokenUsage() + if usage.UsedQuota != 4 || usage.PromptTokens != 3 || usage.CompletionTokens != 1 { + t.Errorf("unexpected usage without total_tokens: %+v", usage) + } + if usage.CacheReadTokens != 1 || usage.CacheWriteTokens != 1 { + t.Errorf("unexpected cache usage: %+v", usage) + } +} + func TestTokenAuthContext(t *testing.T) { req := newTestRequest("", "AI_product") ai := req.InitAiBasicInfo() @@ -699,6 +727,10 @@ func newTestRequestWithCluster(apiKey, product, clusterName, targetModel string) } func buildTestClusterConf(model string, inputCost, outputCost float64) *bfe_cluster.BfeCluster { + return buildTestClusterConfWithCache(model, inputCost, outputCost, 0, 0) +} + +func buildTestClusterConfWithCache(model string, inputCost, outputCost, cacheReadCost, cacheWriteCost float64) *bfe_cluster.BfeCluster { modelTable := &cluster_conf.ModelTable{ Currency: "RMB", Models: []cluster_conf.ModelPrice{ @@ -707,8 +739,10 @@ func buildTestClusterConf(model string, inputCost, outputCost float64) *bfe_clus BaseModel: model, Mode: "chat", Prices: map[string]float64{ - cluster_conf.PriceInputCostPerToken: inputCost, - cluster_conf.PriceOutputCostPerToken: outputCost, + cluster_conf.PriceInputCostPerToken: inputCost, + cluster_conf.PriceOutputCostPerToken: outputCost, + cluster_conf.PriceCacheReadInputTokenCost: cacheReadCost, + cluster_conf.PriceCacheCreationInputTokenCost: cacheWriteCost, }, }, }, @@ -823,6 +857,170 @@ func TestTokenRequestFinishHandler_RMB_NonStreaming(t *testing.T) { } } +func TestTokenRequestFinishHandler_RMB_Cache_NonStreaming(t *testing.T) { + m := NewModuleAITokenAuth() + client := newMockRedisClient() + m.redisClient = client + + clusterName := "claude-backup" + model := "claude-opus-4-6" + req := newTestRequestWithCluster("ak-123", "AI_product", clusterName, model) + + body := `{"usage":{"total_tokens":9500,"prompt_tokens":8000,"completion_tokens":1500,"cache_read_tokens":5000,"cache_write_tokens":1000}}` + res := &bfe_http.Response{ + StatusCode: 200, + ContentLength: int64(len(body)), + Body: ioutil.NopCloser(strings.NewReader(body)), + } + + // Prices chosen so that RmbToFixedPoint yields exact integers: + // input=452, output=2262, cache_read=45, cache_write=565. + cluster := buildTestClusterConfWithCache(model, 0.00000452, 0.00002262, 0.00000045, 0.00000565) + req.SvrDataConf = &mockServerDataConf{clusters: map[string]*bfe_cluster.BfeCluster{clusterName: cluster}} + + rmbPlan := &QuotaPlan{ + Id: "rmb-plan", + RedisKey: "QUOTA_AI_product-CacheNonStreaming", + Unit: "RMB", + Quota: 100000000, + } + SetTokenAuthContext(req, &Token{Key: "ak-123", KeyId: "ak-123-id", QuotaPlans: []*QuotaPlan{rmbPlan}}, 0, nil) + + if ret := m.tokenReadResponseHandler(req, res); ret != bfe_module.BfeHandlerGoOn { + t.Fatalf("read response handler failed: %d", ret) + } + if ret := m.tokenRequestFinishHandler(req, res); ret != bfe_module.BfeHandlerGoOn { + t.Fatalf("expected goon, got %d", ret) + } + + // normal_input = 8000 - 5000 = 3000 + // cost = 3000*452 + 5000*45 + 1000*565 + 1500*2262 = 5539000 + expectedCost := int64(5539000) + usage := req.GetAiBasicInfo().GetTokenUsage() + if usage.UsedCost != expectedCost { + t.Errorf("expected UsedCost %d, got %d", expectedCost, usage.UsedCost) + } + + remaining := client.data[rmbPlan.RedisKey] + if remaining != rmbPlan.Quota-expectedCost { + t.Errorf("expected remaining %d, got %d", rmbPlan.Quota-expectedCost, remaining) + } +} + +func TestTokenRequestFinishHandler_RMB_Cache_Streaming(t *testing.T) { + m := NewModuleAITokenAuth() + client := newMockRedisClient() + m.redisClient = client + + clusterName := "claude-backup" + model := "claude-opus-4-6" + req := newTestRequestWithCluster("ak-123", "AI_product", clusterName, model) + + cluster := buildTestClusterConfWithCache(model, 0.00000452, 0.00002262, 0.00000045, 0.00000565) + req.SvrDataConf = &mockServerDataConf{clusters: map[string]*bfe_cluster.BfeCluster{clusterName: cluster}} + + rmbPlan := &QuotaPlan{ + Id: "rmb-plan", + RedisKey: "QUOTA_AI_product-CacheStreaming", + Unit: "RMB", + Quota: 100000000, + } + SetTokenAuthContext(req, &Token{Key: "ak-123", KeyId: "ak-123-id", QuotaPlans: []*QuotaPlan{rmbPlan}}, 0, nil) + + // Simulate streaming: mod_body_process has filled token usage (including cache fields). + ai := req.GetAiBasicInfo() + usage := ai.GetTokenUsage() + usage.PromptTokens = 8000 + usage.CompletionTokens = 1500 + usage.CacheReadTokens = 5000 + usage.CacheWriteTokens = 1000 + usage.UsedQuota = 9500 + + res := &bfe_http.Response{StatusCode: 200, ContentLength: -1} + if ret := m.tokenRequestFinishHandler(req, res); ret != bfe_module.BfeHandlerGoOn { + t.Fatalf("expected goon, got %d", ret) + } + + expectedCost := int64(5539000) + if usage.UsedCost != expectedCost { + t.Errorf("expected UsedCost %d, got %d", expectedCost, usage.UsedCost) + } + + remaining := client.data[rmbPlan.RedisKey] + if remaining != rmbPlan.Quota-expectedCost { + t.Errorf("expected remaining %d, got %d", rmbPlan.Quota-expectedCost, remaining) + } +} + +func TestCalcCostUnits_Cache(t *testing.T) { + m := NewModuleAITokenAuth() + clusterName := "claude-backup" + model := "claude-opus-4-6" + req := newTestRequestWithCluster("ak-123", "AI_product", clusterName, model) + cluster := buildTestClusterConfWithCache(model, 0.00000452, 0.00002262, 0.00000045, 0.00000565) + req.SvrDataConf = &mockServerDataConf{clusters: map[string]*bfe_cluster.BfeCluster{clusterName: cluster}} + + usage := &bfe_basic.TokenUsage{ + PromptTokens: 8000, + CompletionTokens: 1500, + CacheReadTokens: 5000, + CacheWriteTokens: 1000, + } + + expectedCost := int64(5539000) + got := m.calcCostUnits(req, req.SvrDataConf, usage) + if got != expectedCost { + t.Errorf("expected cost %d, got %d", expectedCost, got) + } +} + +func TestCalcCostUnits_CacheFallback(t *testing.T) { + m := NewModuleAITokenAuth() + clusterName := "deepseek-backup" + model := "deepseek-v4-flash" + req := newTestRequestWithCluster("ak-123", "AI_product", clusterName, model) + cluster := buildTestClusterConf(model, 0.000003, 0.000009) + req.SvrDataConf = &mockServerDataConf{clusters: map[string]*bfe_cluster.BfeCluster{clusterName: cluster}} + + usage := &bfe_basic.TokenUsage{ + PromptTokens: 100, + CompletionTokens: 200, + CacheReadTokens: 50, + CacheWriteTokens: 20, + } + + // No cache prices configured, should fallback to legacy formula. + expectedCost := quota.RmbToFixedPoint(100*0.000003 + 200*0.000009) + got := m.calcCostUnits(req, req.SvrDataConf, usage) + if got != expectedCost { + t.Errorf("expected fallback cost %d, got %d", expectedCost, got) + } +} + +func TestCalcCostUnits_CacheReadExceedsPrompt(t *testing.T) { + m := NewModuleAITokenAuth() + clusterName := "claude-backup" + model := "claude-opus-4-6" + req := newTestRequestWithCluster("ak-123", "AI_product", clusterName, model) + cluster := buildTestClusterConfWithCache(model, 0.00000452, 0.00002262, 0.00000045, 0.00000565) + req.SvrDataConf = &mockServerDataConf{clusters: map[string]*bfe_cluster.BfeCluster{clusterName: cluster}} + + usage := &bfe_basic.TokenUsage{ + PromptTokens: 8000, + CompletionTokens: 1500, + CacheReadTokens: 10000, // exceeds prompt, should be truncated + CacheWriteTokens: 1000, + } + + // normal_input = 0 after truncation + // cost = 8000*45 + 1000*565 + 1500*2262 = 4318000 + expectedCost := int64(4318000) + got := m.calcCostUnits(req, req.SvrDataConf, usage) + if got != expectedCost { + t.Errorf("expected truncated cost %d, got %d", expectedCost, got) + } +} + func TestMonitorAndReloadHandlers(t *testing.T) { m := NewModuleAITokenAuth() mon := m.monitorHandlers() diff --git a/bfe_modules/mod_body_process/body_process.go b/bfe_modules/mod_body_process/body_process.go index 117ef3e5b..3738c119a 100644 --- a/bfe_modules/mod_body_process/body_process.go +++ b/bfe_modules/mod_body_process/body_process.go @@ -426,14 +426,23 @@ func (e *RawEvent) GetQuotaUsage() QuotaUsage { used := gjson.GetBytes(*e, "usage.total_tokens").Int() prompt := gjson.GetBytes(*e, "usage.prompt_tokens").Int() completion := gjson.GetBytes(*e, "usage.completion_tokens").Int() + cacheRead := gjson.GetBytes(*e, "usage.cache_read_tokens").Int() + cacheWrite := gjson.GetBytes(*e, "usage.cache_write_tokens").Int() if used > 0 { isguess = false } else { curtoken = EstimateContentToken(string(*e)) } - return QuotaUsage{PromptTokens: prompt, CompletionTokens: completion, UsedQuota: used, - CurrentTokens: curtoken, IsGuess: isguess} + return QuotaUsage{ + PromptTokens: prompt, + CompletionTokens: completion, + CacheReadTokens: cacheRead, + CacheWriteTokens: cacheWrite, + UsedQuota: used, + CurrentTokens: curtoken, + IsGuess: isguess, + } } type LineDecoder struct { diff --git a/bfe_modules/mod_body_process/content_quota_usage.go b/bfe_modules/mod_body_process/content_quota_usage.go index 73540ede7..bbdee99e3 100644 --- a/bfe_modules/mod_body_process/content_quota_usage.go +++ b/bfe_modules/mod_body_process/content_quota_usage.go @@ -53,11 +53,15 @@ func (caf *QuotaUsageProcessor) Process(events []Event) ([]Event, error) { if rquota.UsedQuota > 0 { tctx.CompletionTokens = rquota.CompletionTokens tctx.PromptTokens = rquota.PromptTokens + tctx.CacheReadTokens = rquota.CacheReadTokens + tctx.CacheWriteTokens = rquota.CacheWriteTokens tctx.UsedQuota = rquota.UsedQuota } else if rquota.PromptTokens > 0 || rquota.CompletionTokens > 0 { tctx.UsedQuota = rquota.PromptTokens + rquota.CompletionTokens tctx.PromptTokens = rquota.PromptTokens tctx.CompletionTokens = rquota.CompletionTokens + tctx.CacheReadTokens = rquota.CacheReadTokens + tctx.CacheWriteTokens = rquota.CacheWriteTokens } } } diff --git a/bfe_modules/mod_body_process/content_quota_usage_test.go b/bfe_modules/mod_body_process/content_quota_usage_test.go index 467387043..44f804a1c 100644 --- a/bfe_modules/mod_body_process/content_quota_usage_test.go +++ b/bfe_modules/mod_body_process/content_quota_usage_test.go @@ -75,3 +75,32 @@ func TestQuotaUsageProcessorProcessEstimate(t *testing.T) { t.Errorf("expected positive completion tokens, got %d", usage.CompletionTokens) } } + +func TestQuotaUsageProcessorProcessWithCache(t *testing.T) { + req := newTestRequest("AI_product") + ai := req.InitAiBasicInfo() + res := &bfe_http.Response{StatusCode: bfe_http.StatusOK} + p := NewQuotaUsageProcessor(req, res) + + events := []Event{newRawEvent(`{"usage":{"total_tokens":9500,"prompt_tokens":8000,"completion_tokens":1500,"cache_read_tokens":5000,"cache_write_tokens":1000}}`)} + out, err := p.Process(events) + if err != nil { + t.Fatalf("Process failed: %s", err) + } + if len(out) != 1 { + t.Errorf("expected 1 event, got %d", len(out)) + } + usage := ai.GetTokenUsage() + if usage.UsedQuota != 9500 { + t.Errorf("expected UsedQuota 9500, got %d", usage.UsedQuota) + } + if usage.PromptTokens != 8000 || usage.CompletionTokens != 1500 { + t.Errorf("unexpected prompt/completion: %+v", usage) + } + if usage.CacheReadTokens != 5000 { + t.Errorf("expected CacheReadTokens 5000, got %d", usage.CacheReadTokens) + } + if usage.CacheWriteTokens != 1000 { + t.Errorf("expected CacheWriteTokens 1000, got %d", usage.CacheWriteTokens) + } +} diff --git a/bfe_modules/mod_body_process/llm_util.go b/bfe_modules/mod_body_process/llm_util.go index 84a07bf9c..90c9b708e 100644 --- a/bfe_modules/mod_body_process/llm_util.go +++ b/bfe_modules/mod_body_process/llm_util.go @@ -32,6 +32,8 @@ type QuotaUsage struct { //return from reponse PromptTokens int64 // number of tokens in the prompt CompletionTokens int64 // number of tokens in the completion + CacheReadTokens int64 // usage.cache_read_tokens, already included in PromptTokens + CacheWriteTokens int64 // usage.cache_write_tokens, independent add-on item UsedQuota int64 // used quota for this request //estimate for current response @@ -120,6 +122,8 @@ func (e *SSEEvent) GetQuotaUsage() QuotaUsage { used := gjson.GetBytes(data, "usage.total_tokens").Int() prompt := gjson.GetBytes(data, "usage.prompt_tokens").Int() completion := gjson.GetBytes(data, "usage.completion_tokens").Int() + cacheRead := gjson.GetBytes(data, "usage.cache_read_tokens").Int() + cacheWrite := gjson.GetBytes(data, "usage.cache_write_tokens").Int() curtoken := int64(0) isguess := true @@ -129,7 +133,15 @@ func (e *SSEEvent) GetQuotaUsage() QuotaUsage { curtoken = EstimateContentToken(string(data)) } - return QuotaUsage{PromptTokens: prompt, CompletionTokens: completion, UsedQuota: used, CurrentTokens: curtoken, IsGuess: isguess} + return QuotaUsage{ + PromptTokens: prompt, + CompletionTokens: completion, + CacheReadTokens: cacheRead, + CacheWriteTokens: cacheWrite, + UsedQuota: used, + CurrentTokens: curtoken, + IsGuess: isguess, + } } func (e *SSEEvent) ToBytes() []byte { diff --git a/docs/zh_cn/modifications/2026-08-22-cache-billing-support/design-changes.md b/docs/zh_cn/modifications/2026-08-22-cache-billing-support/design-changes.md new file mode 100644 index 000000000..0caa12bdc --- /dev/null +++ b/docs/zh_cn/modifications/2026-08-22-cache-billing-support/design-changes.md @@ -0,0 +1,404 @@ +# BFE 支持 Cache 计费能力扩展 + +## 1. 背景 + +AI 网关场景下,部分模型(如 `claude-opus-4-6`)的 usage 中携带 `cache_read_tokens` 和 `cache_write_tokens`,需按 cache 规则分别计费: + +- `prompt_tokens` **已包含** `cache_read_tokens`; +- `cache_write_tokens` 为**独立加项**,不体现在 `prompt_tokens` / `completion_tokens` / `total_tokens` 中; +- 普通 input 计费基数为 `prompt_tokens - cache_read_tokens`。 + +当前 BFE 仅支持 `input_cost_per_token` / `output_cost_per_token` 两个价格字段,无法识别 cache 用量,导致 cache 场景计费金额不准确。本变更在不破坏现有 RMB / total_token 配额逻辑的前提下,扩展 BFE 对 cache 计费的端到端支持。 + +--- + +## 2. 需求示例 + +参考示例 2 的模型与 usage: + +| 价格字段 | USD / token | +|---|---| +| `input_cost_per_token` | `4.525e-06` | +| `output_cost_per_token` | `2.2625e-05` | +| `cache_read_input_token_cost` | `4.525e-07` | +| `cache_creation_input_token_cost` | `5.65625e-06` | + +| Usage 字段 | 值 | +|---|---| +| `prompt_tokens` | 8000 | +| `cache_read_tokens` | 5000 | +| `cache_write_tokens` | 1000 | +| `completion_tokens` | 1500 | + +计费公式: + +``` +normal_input = prompt_tokens - cache_read_tokens = 3000 + +cost = normal_input × input_cost_per_token + + cache_read_tokens × cache_read_input_token_cost + + cache_write_tokens × cache_creation_input_token_cost + + completion_tokens × output_cost_per_token + = 0.05543125 USD +``` + +--- + +## 3. 当前现状 + +| 层级 | 当前能力 | 不足 | +|---|---|---| +| 数据结构 | `bfe_basic.TokenUsage` 仅含 `PromptTokens`、`CompletionTokens`、`UsedQuota`、`UsedCost` | 缺少 cache 字段 | +| 价格配置 | `ModelPrice.Prices` 仅识别 `input_cost_per_token`、`output_cost_per_token` | 缺少 cache 单价 | +| Usage 解析 | 非流式 `UpdateCtxByUsage`、流式 `SSEEvent.GetQuotaUsage` / `QuotaUsageProcessor.Process` 只解析 `total_tokens`、`prompt_tokens`、`completion_tokens` | 不识别 `cache_read_tokens`、`cache_write_tokens` | +| 费用计算 | `calcCostUnits` 仅按 `promptTokens*inputCost + completionTokens*outputCost` 计算 | 无法拆分 cache 计费 | +| 访问日志 | `RequestLog` 只有 `ai_input_tokens`、`ai_output_tokens` 等 | 缺少 cache 明细 | + +--- + +## 4. 变更目标 + +1. 在 `TokenUsage` 中增加 `CacheReadTokens`、`CacheWriteTokens`。 +2. 在模型价格表中支持 `cache_read_input_token_cost`、`cache_creation_input_token_cost`。 +3. 改造 usage 解析、费用计算、访问日志三个环节,识别并正确拆分 cache 计费。 +4. 未配置 cache 单价时退化到原有逻辑,保持向后兼容。 +5. 保持 `UsedQuota`(total_token 维度)为 `prompt_tokens + completion_tokens`,与现有配额机制对齐。 +6. 补充单元测试与集成测试。 + +--- + +## 5. 变更总览 + +| 模块 | 主要改动 | +|---|---| +| `bfe_basic` | `TokenUsage` 增加 `CacheReadTokens`、`CacheWriteTokens` | +| `bfe_config/bfe_cluster_conf/cluster_conf` | 增加 cache 价格常量,`ModelTableCheck` 识别并转换 cache 单价为定点整数 | +| `mod_ai_token_auth` | `UpdateCtxByUsage` 解析 cache usage;`calcCostUnits` 按 cache 拆分公式计费 | +| `mod_body_process` | `SSEEvent.GetQuotaUsage`、`QuotaUsageProcessor.Process` 解析并累积 cache usage | +| `bfe-access-pb` / `mod_access_pb3` | 访问日志新增 `ai_cache_read_tokens`、`ai_cache_write_tokens`(可选但建议) | +| 测试 | 补充 cache 计费、流式/非流式、无 cache 单价退化等测试 | + +--- + +## 6. 详细设计 + +### 6.1 扩展 `TokenUsage` + +**文件:** `bfe/bfe_basic/request_ai_basic.go` + +```go +type TokenUsage struct { + PromptTokens int64 // 含 cache_read + CompletionTokens int64 + CacheReadTokens int64 // usage.cache_read_tokens,已包含在 PromptTokens 中 + CacheWriteTokens int64 // usage.cache_write_tokens,独立加项 + UsedQuota int64 // unit=total_token + UsedCost int64 // unit=RMB,1 unit = 1e-8 yuan +} +``` + +说明: +- `UsedQuota` 保持为 `prompt_tokens + completion_tokens`,用于 `total_token` 配额扣减。 +- RMB 计费使用 `PromptTokens - CacheReadTokens` 作为普通 input,避免 cache read 被重复计费。 + +### 6.2 扩展价格配置 + +**文件:** `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` + +新增常量: + +```go +const ( + PriceInputCostPerToken = "input_cost_per_token" + PriceOutputCostPerToken = "output_cost_per_token" + PriceCacheReadInputTokenCost = "cache_read_input_token_cost" + PriceCacheCreationInputTokenCost = "cache_creation_input_token_cost" + + PriceInputCostPerTokenInt = "input_cost_per_token_int" + PriceOutputCostPerTokenInt = "output_cost_per_token_int" + PriceCacheReadInputTokenCostInt = "cache_read_input_token_cost_int" + PriceCacheCreationInputTokenCostInt = "cache_creation_input_token_cost_int" +) +``` + +在 `ModelTableCheck` 中增加 cache 单价的读取与定点转换: + +```go +input := price.Prices[PriceInputCostPerToken] +output := price.Prices[PriceOutputCostPerToken] +cacheRead := price.Prices[PriceCacheReadInputTokenCost] +cacheWrite := price.Prices[PriceCacheCreationInputTokenCost] + +if input < 0 || output < 0 || cacheRead < 0 || cacheWrite < 0 { + return fmt.Errorf("negative price for model %s", price.Model) +} + +price.Prices[PriceInputCostPerTokenInt] = float64(quota.RmbToFixedPoint(input)) +price.Prices[PriceOutputCostPerTokenInt] = float64(quota.RmbToFixedPoint(output)) +price.Prices[PriceCacheReadInputTokenCostInt] = float64(quota.RmbToFixedPoint(cacheRead)) +price.Prices[PriceCacheCreationInputTokenCostInt] = float64(quota.RmbToFixedPoint(cacheWrite)) +``` + +cache 单价为可选配置: +- 配置了 cache 单价 → 按 cache 拆分计费; +- 未配置 → 退化到 `promptTokens*inputCost + completionTokens*outputCost`。 + +### 6.3 非流式 Usage 解析 + +**文件:** `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` + +函数 `UpdateCtxByUsage` 增加 cache 字段解析: + +```go +cacheRead := gjson.GetBytes(data, "usage.cache_read_tokens").Int() +cacheWrite := gjson.GetBytes(data, "usage.cache_write_tokens").Int() + +tokenUsage.CacheReadTokens = cacheRead +tokenUsage.CacheWriteTokens = cacheWrite +``` + +### 6.4 流式 Usage 解析 + +**文件:** `bfe/bfe_modules/mod_body_process/llm_util.go` + +函数 `SSEEvent.GetQuotaUsage` 返回的 `QuotaUsage` 增加 cache 字段: + +```go +type QuotaUsage struct { + PromptTokens int64 + CompletionTokens int64 + CacheReadTokens int64 + CacheWriteTokens int64 + UsedQuota int64 + CurrentTokens int64 + IsGuess bool +} +``` + +函数内解析 `usage.cache_read_tokens`、`usage.cache_write_tokens`。 + +**文件:** `bfe/bfe_modules/mod_body_process/body_process.go` + +非流式响应使用的 `RawEvent.GetQuotaUsage` 同样需要解析 `usage.cache_read_tokens`、`usage.cache_write_tokens`,以保证 `QuotaUsageProcessor` 在两种响应格式下都能正确收集 cache 用量。 + +**文件:** `bfe/bfe_modules/mod_body_process/content_quota_usage.go` + +`QuotaUsageProcessor.Process` 在非 guess 事件覆盖 `tctx` 时同步写入 `CacheReadTokens`、`CacheWriteTokens`。 + +> 流式场景下 cache usage 通常只在最后一个 SSE 事件中出现,因此最后一个有效事件会覆盖之前的值。 + +### 6.5 RMB 费用计算改造 + +**文件:** `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` + +函数 `calcCostUnits` 签名增加 `usage *bfe_basic.TokenUsage`: + +```go +func (m *ModuleAITokenAuth) calcCostUnits( + req *bfe_basic.Request, + serverConf bfe_basic.ServerDataConfInterface, + usage *bfe_basic.TokenUsage, +) int64 +``` + +计费逻辑: + +```go +promptTokens := usage.PromptTokens +completionTokens := usage.CompletionTokens +cacheReadTokens := usage.CacheReadTokens +cacheWriteTokens := usage.CacheWriteTokens + +// 边界保护 +if cacheReadTokens < 0 { + cacheReadTokens = 0 +} +if cacheReadTokens > promptTokens { + cacheReadTokens = promptTokens +} +if cacheWriteTokens < 0 { + cacheWriteTokens = 0 +} + +cacheReadCost := int64(entry.Prices[cluster_conf.PriceCacheReadInputTokenCostInt]) +cacheWriteCost := int64(entry.Prices[cluster_conf.PriceCacheCreationInputTokenCostInt]) + +var cost int64 +if cacheReadCost > 0 || cacheWriteCost > 0 { + normalInput := promptTokens - cacheReadTokens + if normalInput < 0 { + normalInput = 0 + } + cost = normalInput*inputCost + + cacheReadTokens*cacheReadCost + + cacheWriteTokens*cacheWriteCost + + completionTokens*outputCost +} else { + cost = promptTokens*inputCost + completionTokens*outputCost +} + +return cost +``` + +调用点同步修改: + +```go +if tokenUsage.UsedCost <= 0 && hasRMBPlan(ctx.Token.QuotaPlans) { + tokenUsage.UsedCost = m.calcCostUnits(req, ctx.serverConf, tokenUsage) +} +``` + +### 6.6 访问日志扩展(建议) + +**文件:** `bfe-access-pb/bfe_access_pb/bfe_access.proto` + +在 AI Observability 区域新增字段: + +```protobuf +optional int64 ai_cache_read_tokens = 781; +optional int64 ai_cache_write_tokens = 782; +``` + +**文件:** `bfe/bfe_modules/mod_access_pb3/request_log.go` + +在 `reqAiInfoGen` 中补充: + +```go +if usage.CacheReadTokens > 0 { + reqLog.AiCacheReadTokens = proto.Int64(usage.CacheReadTokens) +} +if usage.CacheWriteTokens > 0 { + reqLog.AiCacheWriteTokens = proto.Int64(usage.CacheWriteTokens) +} +``` + +修改 proto 后需执行 `bfe-access-pb/build.sh` 重新生成 Go 代码。 + +--- + +## 7. 计费公式速查 + +### 7.1 含 Cache 的模型 + +前提:`cache_read_input_token_cost` 或 `cache_creation_input_token_cost` 已配置。 + +``` +normal_input = max(prompt_tokens - cache_read_tokens, 0) + +cost = normal_input × input_cost_per_token + + cache_read_tokens × cache_read_input_token_cost + + cache_write_tokens × cache_creation_input_token_cost + + completion_tokens × output_cost_per_token +``` + +### 7.2 不含 Cache 的模型(向后兼容) + +``` +cost = prompt_tokens × input_cost_per_token + + completion_tokens × output_cost_per_token +``` + +--- + +## 8. 边界情况与兼容性 + +| 场景 | 处理建议 | +|---|---| +| 后端未返回 cache 字段 | 字段值为 0,按不含 cache 计费 | +| `cache_read_tokens > prompt_tokens` | 截断为 `prompt_tokens`,避免普通 input 为负 | +| `cache_read_tokens` 或 `cache_write_tokens` 为负 | 按 0 处理 | +| 价格表只配置了部分 cache 单价 | 只要任一 cache 单价 > 0,启用 cache 拆分;否则退化 | +| 流式响应中 cache usage 出现在中间事件 | 非 guess 事件覆盖,最终事件为准 | +| `UsedQuota` 维度 | 继续为 `prompt_tokens + completion_tokens`,cache_write 不累加 | +| 非 chat 模式 | 当前 BFE 仅支持 chat;若后续扩展,需同步修改 `LookupModelPrice` 的 mode 参数 | + +--- + +## 9. 测试计划 + +### 9.1 单元测试 + +在 `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go` 中新增: + +1. `TestCalcCostUnits_Cache`:验证 cache 拆分计费公式。 +2. `TestUpdateCtxByUsage_Cache`:验证 cache 字段解析。 +3. `TestTokenRequestFinishHandler_RMB_Cache_NonStreaming`:非流式端到端扣费。 +4. `TestTokenRequestFinishHandler_RMB_Cache_Streaming`:流式端到端扣费。 +5. `TestCalcCostUnits_CacheFallback`:未配置 cache 单价时退化。 +6. `TestCalcCostUnits_CacheReadExceedsPrompt`:边界处理。 + +### 9.2 集成测试 + +在 `bfe/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go` 中新增: + +1. `TestTC08_RMBQuotaDeduction_Cache_NonStreaming`: + - 后端返回含 `cache_read_tokens` / `cache_write_tokens` 的非流式响应; + - `ModelTable` 配置 cache 单价; + - 验证 Redis 扣减金额按 cache 拆分公式计算。 +2. `TestTC09_RMBQuotaDeduction_Cache_Streaming`: + - 后端返回 SSE 流,最终 chunk 含 cache usage; + - 验证流式场景下 Redis 仍按 cache 拆分公式扣减。 + +### 9.3 配置加载测试 + +在 `bfe/bfe_config/bfe_cluster_conf/cluster_conf/` 中: + +1. 验证 `ModelTableCheck` 正确转换 cache 单价为定点整数; +2. 验证未配置 cache 单价时不报错。 + +--- + +## 10. 实施步骤建议 + +1. **数据结构与配置层**:扩展 `TokenUsage`;增加 cache 价格常量及转换逻辑。 +2. **Usage 解析**:非流式与流式模块同时解析 cache usage。 +3. **计费逻辑**:改造 `calcCostUnits`,实现 cache 拆分计费及退化逻辑;同步调用点与单元测试。 +4. **日志与可观测性**:扩展 `bfe_access.proto` 与 `request_log.go`。 +5. **测试与回归**:补充单元测试、集成测试,验证现有 RMB / total_token 配额场景不受影响。 + +--- + +## 11. 影响范围 + +| 模块/文件 | 影响 | +|---|---| +| `bfe/bfe_basic/request_ai_basic.go` | `TokenUsage` 新增 cache 字段 | +| `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` | 新增 cache 价格常量与转换逻辑 | +| `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` | Usage 解析与费用计算改造 | +| `bfe/bfe_modules/mod_body_process/llm_util.go` | `QuotaUsage` 与 `SSEEvent.GetQuotaUsage` 新增 cache 字段 | +| `bfe/bfe_modules/mod_body_process/body_process.go` | `RawEvent.GetQuotaUsage` 新增 cache 字段 | +| `bfe/bfe_modules/mod_body_process/content_quota_usage.go` | 流式/非流式 cache usage 累积 | +| `bfe-access-pb/bfe_access_pb/bfe_access.proto` | 访问日志新增 cache 字段 | +| `bfe/bfe_modules/mod_access_pb3/request_log.go` | 序列化 cache 字段 | +| `bfe/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go` | 新增 cache 计费集成测试 | +| `bfe/docs/zh_cn/modifications/2026-08-22-cache-billing-support/design-changes.md` | 本设计变更文档 | + +--- + +## 12. 兼容性与风险 + +### 12.1 兼容性 + +- 未配置 cache 单价的模型行为完全不变。 +- Token 配额(`total_token`)扣减逻辑不变。 +- Redis key 结构、配置格式均不发生改变。 +- `UsedQuota` 继续保持 `prompt_tokens + completion_tokens`。 + +### 12.2 风险与缓解 + +| 风险 | 缓解措施 | +|---|---| +| 后端返回异常 cache 数据导致普通 input 为负 | `calcCostUnits` 中校验并截断 `cache_read_tokens` | +| 流式 cache usage 事件提前或延后 | `QuotaUsageProcessor` 在非 guess 事件时覆盖,最终以可靠事件为准 | +| proto 字段变更需重新生成 | 修改后执行 `bfe-access-pb/build.sh` | + +--- + +## 13. 参考资料 + +- `document-ai-gateway/迭代系统设计/v0.5/计费能力扩展/bfe-cache-billing-support-analysis.md` +- `document-ai-gateway/迭代系统设计/v0.4/quota-rmb-support/bfe-changes-for-rmb-quota.md` +- `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` +- `bfe/bfe_modules/mod_body_process/content_quota_usage.go` +- `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` +- `bfe/bfe_basic/request_ai_basic.go` diff --git a/go.mod b/go.mod index b82a7d98a..e6ed060f4 100644 --- a/go.mod +++ b/go.mod @@ -70,7 +70,7 @@ require ( require ( github.com/HdrHistogram/hdrhistogram-go v1.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/bfenetworks/bfe-access-pb v0.3.0 + github.com/bfenetworks/bfe-access-pb v0.3.1 github.com/bfenetworks/bfe-mock-waf v0.1.0 github.com/bfenetworks/bwi v0.1.2 github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index eef1782b8..8f2a505ae 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bfenetworks/bfe-access-pb v0.3.0 h1:DXfFJ3RdBRTrsllFsGA0ElYjd6Whbj0JxDMV7NnbTj0= -github.com/bfenetworks/bfe-access-pb v0.3.0/go.mod h1:3qunybhMV5hEeT/2qDwbchxo4t/9LPKEvwwZkmekRho= +github.com/bfenetworks/bfe-access-pb v0.3.1 h1:+eRfg0wz6NHLUXSWdmZs2FUP3nckyhK0dxz2XAzzDo4= +github.com/bfenetworks/bfe-access-pb v0.3.1/go.mod h1:3qunybhMV5hEeT/2qDwbchxo4t/9LPKEvwwZkmekRho= github.com/bfenetworks/bfe-mock-waf v0.1.0 h1:dTd540S3nv6qNlG6lLC0F8qx3gyo5WB4wnci3+d4J78= github.com/bfenetworks/bfe-mock-waf v0.1.0/go.mod h1:MWZHbihiRQXpoUCvY1l18s2bfOBWx4N4pghxBt+xUv0= github.com/bfenetworks/bwi v0.1.2 h1:3AcCzUjyzKm+FeLgTIVg58u+SUdEVZdJbQM58Ezwjcg= diff --git a/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go b/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go index e43c33758..2becd6de8 100644 --- a/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go +++ b/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go @@ -49,6 +49,8 @@ const ( var defaultBody = []byte(`{"model":"deepseek-chat"}`) var modelMappingBody = []byte(`{"model":"gpt-4"}`) var streamBody = []byte(`{"model":"deepseek-chat","stream":true}`) +var cacheBody = []byte(`{"model":"claude-opus-4-6"}`) +var cacheStreamBody = []byte(`{"model":"claude-opus-4-6","stream":true}`) var usageResponse = `{"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}}` @@ -57,6 +59,13 @@ var streamUsageResponse = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\ "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n" + "data: {\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":50,\"total_tokens\":150}}\n\n" +var cacheUsageResponse = `{"usage":{"prompt_tokens":8000,"completion_tokens":1500,"total_tokens":9500,"cache_read_tokens":5000,"cache_write_tokens":1000}}` + +// SSE format with cache usage in the final chunk. +var cacheStreamUsageResponse = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n" + + "data: {\"usage\":{\"prompt_tokens\":8000,\"completion_tokens\":1500,\"total_tokens\":9500,\"cache_read_tokens\":5000,\"cache_write_tokens\":1000}}\n\n" + // testEnv holds all resources for a single SC03 integration test. type testEnv struct { t *testing.T @@ -219,6 +228,23 @@ func defaultRMBAIConf() *cluster_conf.AIConf { "output_cost_per_token": 0.000002, }, }, + { + Provider: "mock-provider", + Model: "claude-opus-4-6", + BaseModel: "claude-opus-4-6", + Mode: "chat", + Capabilities: []string{"chat"}, + SupportedParameters: []string{"temperature", "max_tokens"}, + Limits: map[string]interface{}{ + "context_window": 128000, + }, + Prices: map[string]float64{ + "input_cost_per_token": 0.00000452, + "output_cost_per_token": 0.00002262, + "cache_read_input_token_cost": 0.00000045, + "cache_creation_input_token_cost": 0.00000565, + }, + }, }, }, } @@ -501,3 +527,80 @@ func TestTC07_RMBQuotaDeduction_Streaming(t *testing.T) { t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) } } + +// TestTC08 verifies RMB quota deduction with cache pricing for non-streaming responses. +func TestTC08_RMBQuotaDeduction_Cache_NonStreaming(t *testing.T) { + aiConfs := map[string]*cluster_conf.AIConf{ + clusterRMB: defaultRMBAIConf(), + } + e := newTestEnv(t, aiConfs, []common.QuotaPlan{rmbQuotaPlan(10000000000)}) + defer e.Close() + + e.redis.SetQuota(redisKeyRMB, 10000000000) + + // Configure backend to return response with cache usage. + e.backends[clusterRMB].Body = cacheUsageResponse + + resp, body, err := e.sendRequest(apiHost, cacheBody) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if e.backends[clusterRMB].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterRMB, e.backends[clusterRMB].Hits()) + } + + // Wait for async redis deduction. + time.Sleep(500 * time.Millisecond) + remaining := e.redis.GetQuota(redisKeyRMB) + // normal_input = 8000 - 5000 = 3000 + // cost = 3000*452 + 5000*45 + 1000*565 + 1500*2262 = 5539000 + want := int64(10000000000 - 5539000) + if remaining != want { + e.logBFEException() + e.logBFEAccess() + t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) + } +} + +// TestTC09 verifies RMB quota deduction with cache pricing for streaming (SSE) responses. +func TestTC09_RMBQuotaDeduction_Cache_Streaming(t *testing.T) { + aiConfs := map[string]*cluster_conf.AIConf{ + clusterRMB: defaultRMBAIConf(), + } + e := newTestEnv(t, aiConfs, []common.QuotaPlan{rmbQuotaPlan(10000000000)}) + defer e.Close() + + e.redis.SetQuota(redisKeyRMB, 10000000000) + + // Configure backend to return SSE stream with cache usage in the final chunk. + e.backends[clusterRMB].ResponseHeaders = map[string]string{"Content-Type": "text/event-stream"} + e.backends[clusterRMB].Body = cacheStreamUsageResponse + + resp, body, err := e.sendRequest(apiHost, cacheStreamBody) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if e.backends[clusterRMB].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterRMB, e.backends[clusterRMB].Hits()) + } + + // Wait for async redis deduction after response finishes. + time.Sleep(500 * time.Millisecond) + remaining := e.redis.GetQuota(redisKeyRMB) + want := int64(10000000000 - 5539000) + if remaining != want { + e.logBFEException() + e.logBFEAccess() + t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) + } +} diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-07-Cache\346\250\241\345\236\213\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-07-Cache\346\250\241\345\236\213\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" new file mode 100644 index 000000000..8b95c2545 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-07-Cache\346\250\241\345\236\213\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" @@ -0,0 +1,82 @@ +# TC-07 Cache 模型非流式计费 + +## 用例编号与名称 + +TC-07 Cache 模型非流式计费 + +## 所属场景 + +SC03 RMB 配额扣减 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证当模型价格表中配置了 cache 相关单价时,BFE 对非流式响应按 cache 拆分公式计费: + +``` +normal_input = prompt_tokens - cache_read_tokens +cost = normal_input * input_cost + cache_read_tokens * cache_read_cost + + cache_write_tokens * cache_creation_cost + completion_tokens * output_cost +``` + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动,并预置 `quota:plan_rmb = 10000000000`。 +3. mock 后端 `cluster_rmb` 已启动,返回 200 与如下 body: + ```json + { + "usage": { + "prompt_tokens": 8000, + "completion_tokens": 1500, + "total_tokens": 9500, + "cache_read_tokens": 5000, + "cache_write_tokens": 1000 + } + } + ``` +4. 临时 BFE 配置已加载,`cluster_rmb` 的 `ModelTable` 包含模型 `claude-opus-4-6`,价格: + - `input_cost_per_token`: `0.00000452` → 定点整数 `452` + - `output_cost_per_token`: `0.00002262` → 定点整数 `2262` + - `cache_read_input_token_cost`: `0.00000045` → 定点整数 `45` + - `cache_creation_input_token_cost`: `0.00000565` → 定点整数 `565` +5. `ak_user_a` 绑定 RMB 配额计划 `plan_rmb`。 + +## 配置构造 + +- `cluster_rmb.AIConf.ModelTable.Models` 增加模型 `claude-opus-4-6`: + - `input_cost_per_token`: `0.00000452` + - `output_cost_per_token`: `0.00002262` + - `cache_read_input_token_cost`: `0.00000045` + - `cache_creation_input_token_cost`: `0.00000565` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `rmb.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"claude-opus-4-6"}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_rmb` 收到 1 次命中(200)。 +- Redis 中 `quota:plan_rmb` 的余额按 cache 拆分公式扣减: + - normal_input = 8000 - 5000 = 3000 + - 扣减金额 = 3000 * 452 + 5000 * 45 + 1000 * 565 + 1500 * 2262 = 5539000 + - 剩余 = `10000000000 - 5539000 = 99994461000` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-08-Cache\346\250\241\345\236\213\346\265\201\345\274\217\350\256\241\350\264\271.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-08-Cache\346\250\241\345\236\213\346\265\201\345\274\217\350\256\241\350\264\271.md" new file mode 100644 index 000000000..2f8814fac --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-08-Cache\346\250\241\345\236\213\346\265\201\345\274\217\350\256\241\350\264\271.md" @@ -0,0 +1,74 @@ +# TC-08 Cache 模型流式计费 + +## 用例编号与名称 + +TC-08 Cache 模型流式计费 + +## 所属场景 + +SC03 RMB 配额扣减 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证当模型价格表中配置了 cache 相关单价时,BFE 对流式(SSE)响应按 cache 拆分公式计费。cache usage 通常只在 SSE 流的最终 chunk 中出现。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动,并预置 `quota:plan_rmb = 10000000000`。 +3. mock 后端 `cluster_rmb` 已启动,返回 `Content-Type: text/event-stream` 的 SSE 流,最终 chunk 含 cache usage: + ``` + data: {"choices":[{"delta":{"role":"assistant"}}]} + + data: {"choices":[{"delta":{"content":"hello"}}]} + + data: {"usage":{"prompt_tokens":8000,"completion_tokens":1500,"total_tokens":9500,"cache_read_tokens":5000,"cache_write_tokens":1000}} + + ``` +4. 临时 BFE 配置已加载,`cluster_rmb` 的 `ModelTable` 包含模型 `claude-opus-4-6`,价格: + - `input_cost_per_token`: `0.00000452` → 定点整数 `452` + - `output_cost_per_token`: `0.00002262` → 定点整数 `2262` + - `cache_read_input_token_cost`: `0.00000045` → 定点整数 `45` + - `cache_creation_input_token_cost`: `0.00000565` → 定点整数 `565` +5. `ak_user_a` 绑定 RMB 配额计划 `plan_rmb`。 + +## 配置构造 + +- `cluster_rmb.AIConf.ModelTable.Models` 增加模型 `claude-opus-4-6`: + - `input_cost_per_token`: `0.00000452` + - `output_cost_per_token`: `0.00002262` + - `cache_read_input_token_cost`: `0.00000045` + - `cache_creation_input_token_cost`: `0.00000565` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `rmb.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"claude-opus-4-6","stream":true}` | + +## 预期结果 + +- 响应状态码:200。 +- 响应 `Content-Type` 为 `text/event-stream`。 +- `cluster_rmb` 收到 1 次命中(200)。 +- Redis 中 `quota:plan_rmb` 的余额按 cache 拆分公式扣减: + - normal_input = 8000 - 5000 = 3000 + - 扣减金额 = 3000 * 452 + 5000 * 45 + 1000 * 565 + 1500 * 2262 = 5539000 + - 剩余 = `10000000000 - 5539000 = 99994461000` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" index 94b4b0bae..1915322f5 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" @@ -11,7 +11,8 @@ BFE v0.4 在 `mod_ai_token_auth` 中引入 RMB(人民币)配额支持。与 - `ModelMapping` 场景下按映射后的最终模型计费; - Token 配额与 RMB 配额共存时分别扣减; - cluster 未配置 `ModelTable` 或模型未命中时按 0 成本处理; -- fallback 到另一个 cluster 后按最终 cluster + target_model 计费。 +- fallback 到另一个 cluster 后按最终 cluster + target_model 计费; +- 含 cache 的模型按 cache 拆分公式计费(非流式与流式)。 ## 2. 运行模式 @@ -82,6 +83,23 @@ BFE v0.4 在 `mod_ai_token_auth` 中引入 RMB(人民币)配额支持。与 "input_cost_per_token": 0.000001, "output_cost_per_token": 0.000002 } + }, + { + "Provider": "mock-provider", + "Model": "claude-opus-4-6", + "BaseModel": "claude-opus-4-6", + "Mode": "chat", + "Capabilities": ["chat"], + "SupportedParameters": ["temperature", "max_tokens"], + "Limits": { + "context_window": 128000 + }, + "Prices": { + "input_cost_per_token": 0.00000452, + "output_cost_per_token": 0.00002262, + "cache_read_input_token_cost": 0.00000045, + "cache_creation_input_token_cost": 0.00000565 + } } ] } @@ -93,6 +111,11 @@ BFE v0.4 在 `mod_ai_token_auth` 中引入 RMB(人民币)配额支持。与 > - `input_cost_per_token = 0.000001` 元/Token,定点整数为 `100`(1e-8 元/Token)。 > - `output_cost_per_token = 0.000002` 元/Token,定点整数为 `200`。 > - 请求 `gpt-4` 经 `ModelMapping` 映射为 `deepseek-chat`,按 `deepseek-chat` 价格计费。 +> - `claude-opus-4-6` 配置 cache 单价: +> - `input_cost_per_token = 0.00000452` → 定点整数 `452` +> - `output_cost_per_token = 0.00002262` → 定点整数 `2262` +> - `cache_read_input_token_cost = 0.00000045` → 定点整数 `45` +> - `cache_creation_input_token_cost = 0.00000565` → 定点整数 `565` ### 4.3 `cluster_fallback_rmb` 的 `AIConf` @@ -181,6 +204,8 @@ BFE v0.4 在 `mod_ai_token_auth` 中引入 RMB(人民币)配额支持。与 | TC-04 | Token 与 RMB 配额共存 | 同时绑定 Token 和 RMB 计划,两者分别扣减正确金额 | | TC-05 | 无 ModelTable 按 0 成本处理 | `cluster_no_table` 命中 RMB 计划但无定价表,请求成功且 Redis 余额不变 | | TC-06 | Fallback 后按最终 cluster 计费 | `cluster_rmb` 返回 502 触发 fallback 到 `cluster_fallback_rmb`,按后者价格扣减 | +| TC-07 | Cache 模型非流式计费 | 后端返回含 `cache_read_tokens` / `cache_write_tokens` 的响应,按 cache 拆分公式扣减 | +| TC-08 | Cache 模型流式计费 | SSE 最终 chunk 含 cache usage,按 cache 拆分公式扣减 | ## 7. 公共基础设施改造 From 9b06887e4926b33cab2237e578ec043e5177b056 Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Sat, 22 Aug 2026 09:13:24 +0800 Subject: [PATCH 03/18] test(bfe): add SC05 integration test for cache token access log fields - Add TestTC08_CacheTokenFields to verify ai_cache_read_tokens, ai_cache_write_tokens and cache-aware ai_cost_value in access log. - Update SC05 test design docs with TC-08. - Update cache billing design-changes.md to include SC05 access log test. Signed-off-by: zhangmiao --- .../design-changes.md | 8 ++ .../sc05_access_log_ai_fields_test.go | 53 +++++++++++ ...08-Cache Token\345\255\227\346\256\265.md" | 91 +++++++++++++++++++ ...72\346\231\257\350\257\264\346\230\216.md" | 4 +- 4 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-08-Cache Token\345\255\227\346\256\265.md" diff --git a/docs/zh_cn/modifications/2026-08-22-cache-billing-support/design-changes.md b/docs/zh_cn/modifications/2026-08-22-cache-billing-support/design-changes.md index 0caa12bdc..04ede36a7 100644 --- a/docs/zh_cn/modifications/2026-08-22-cache-billing-support/design-changes.md +++ b/docs/zh_cn/modifications/2026-08-22-cache-billing-support/design-changes.md @@ -339,6 +339,13 @@ cost = prompt_tokens × input_cost_per_token - 后端返回 SSE 流,最终 chunk 含 cache usage; - 验证流式场景下 Redis 仍按 cache 拆分公式扣减。 +在 `bfe/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go` 中新增: + +3. `TestTC08_CacheTokenFields`: + - 后端返回含 `cache_read_tokens` / `cache_write_tokens` 的 usage; + - `ModelTable` 配置 cache 单价; + - 验证 `mod_access_pb3` 输出的 b2log 中 `ai_cache_read_tokens`、`ai_cache_write_tokens` 与 cache-aware `ai_cost_value` 正确。 + ### 9.3 配置加载测试 在 `bfe/bfe_config/bfe_cluster_conf/cluster_conf/` 中: @@ -371,6 +378,7 @@ cost = prompt_tokens × input_cost_per_token | `bfe-access-pb/bfe_access_pb/bfe_access.proto` | 访问日志新增 cache 字段 | | `bfe/bfe_modules/mod_access_pb3/request_log.go` | 序列化 cache 字段 | | `bfe/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go` | 新增 cache 计费集成测试 | +| `bfe/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go` | 新增 cache 访问日志字段集成测试 | | `bfe/docs/zh_cn/modifications/2026-08-22-cache-billing-support/design-changes.md` | 本设计变更文档 | --- diff --git a/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go b/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go index 3e843b902..3010f13e6 100644 --- a/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go +++ b/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go @@ -54,6 +54,7 @@ var modelMappingBody = []byte(`{"model":"gpt-4"}`) var streamBody = []byte(`{"model":"deepseek-chat","stream":true}`) var usageResponse = `{"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}}` +var cacheUsageResponse = `{"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150,"cache_read_tokens":30,"cache_write_tokens":20}}` // SSE format: final chunk contains usage. The trailing blank line is required. var streamUsageResponse = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n" + @@ -271,6 +272,17 @@ func fallbackRMBAIConf() *cluster_conf.AIConf { return conf } +func cacheRMBAIConf() *cluster_conf.AIConf { + conf := defaultRMBAIConf() + conf.ModelTable.Models[0].Prices = map[string]float64{ + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": 0.0000005, + "cache_creation_input_token_cost": 0.0000015, + } + return conf +} + func noTableAIConf() *cluster_conf.AIConf { return &cluster_conf.AIConf{ Type: 0, @@ -811,3 +823,44 @@ func TestTC07_RateLimitHits(t *testing.T) { t.Errorf("rate_limit rule_names should not be empty") } } + +// TestTC08 verifies ai_cache_read_tokens, ai_cache_write_tokens and cache-aware cost. +func TestTC08_CacheTokenFields(t *testing.T) { + aiConfs := map[string]*cluster_conf.AIConf{ + clusterRMB: cacheRMBAIConf(), + } + e := newTestEnv(t, aiConfs, []common.QuotaPlan{rmbQuotaPlan(10000000000)}, false) + defer e.Close() + + e.redis.SetQuota(redisKeyRMB, 10000000000) + e.backends[clusterRMB].Body = cacheUsageResponse + + resp, body, err := e.sendRequest(apiHost, defaultBody) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if e.backends[clusterRMB].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterRMB, e.backends[clusterRMB].Hits()) + } + + // Wait for access log to be flushed before stopping BFE. + time.Sleep(500 * time.Millisecond) + + e.stopBFE() + e.stopBFE = nil + + reqLog := e.mustFindSingleLog(e.accessLogs()) + assertInt64Field(t, reqLog.AiInputTokens, "ai_input_tokens", 100) + assertInt64Field(t, reqLog.AiOutputTokens, "ai_output_tokens", 50) + assertInt64Field(t, reqLog.AiTotalTokens, "ai_total_tokens", 150) + assertInt64Field(t, reqLog.AiCacheReadTokens, "ai_cache_read_tokens", 30) + assertInt64Field(t, reqLog.AiCacheWriteTokens, "ai_cache_write_tokens", 20) + // cost = (100-30)*100 + 30*50 + 20*150 + 50*200 = 21500 + assertInt64Field(t, reqLog.AiCostValue, "ai_cost_value", 21500) + assertStringField(t, reqLog.AiCostCurrency, "ai_cost_currency", "RMB") +} diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-08-Cache Token\345\255\227\346\256\265.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-08-Cache Token\345\255\227\346\256\265.md" new file mode 100644 index 000000000..44b9582d8 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-08-Cache Token\345\255\227\346\256\265.md" @@ -0,0 +1,91 @@ +# TC-08 Cache Token 字段 + +## 用例编号与名称 + +TC-08 Cache Token 字段 + +## 所属场景 + +SC05 AI 访问日志字段校验 + +## 版本声明 + +- `bfe`:当前源码版本 +- `bfe-access-pb`:`v0.3.1` + +## 测试目的 + +验证后端返回包含 `cache_read_tokens` / `cache_write_tokens` 的 usage 时,`mod_access_pb3` 输出的 b2log 中 `ai_cache_read_tokens`、`ai_cache_write_tokens` 被正确填充,且 `ai_cost_value` 按 cache-aware 公式计算。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动,并预置 `quota:plan_rmb = 10000000000`(100 元)。 +3. mock 后端 `cluster_rmb` 已启动,返回 200 与如下 body: + ```json + { + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "cache_read_tokens": 30, + "cache_write_tokens": 20 + } + } + ``` +4. 临时 BFE 配置已加载,`cluster_rmb` 配置 `Provider = "mock-provider"`、`ModelTable.Currency = "RMB"`,且价格表包含 cache 价格: + - `input_cost_per_token = 0.000001` + - `output_cost_per_token = 0.000002` + - `cache_read_input_token_cost = 0.0000005` + - `cache_creation_input_token_cost = 0.0000015` +5. `ak_user_a` 绑定 RMB 配额计划 `plan_rmb`。 +6. 启用 `mod_access_pb3`,b2log 输出到临时 `log/` 目录。 + +## 配置构造 + +- `cluster_rmb.AIConf`: + - `Provider`: `mock-provider` + - `ModelTable.Currency`: `RMB` + - `ModelTable.Models[0].Model`: `deepseek-chat` + - `Prices.input_cost_per_token`: `0.000001` + - `Prices.output_cost_per_token`: `0.000002` + - `Prices.cache_read_input_token_cost`: `0.0000005` + - `Prices.cache_creation_input_token_cost`: `0.0000015` + - `Keys`: 单 Key `key-primary` +- `plan_rmb`: + - `Unit`: `RMB` + - `Quota`: `10000000000` + - `RedisKey`: `quota:plan_rmb` +- `ai_route.data` 中 `apikey_ak_user_a` 命中 `user_a-rmb`,target 为 `cluster_rmb`。 + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `rmb.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"deepseek-chat"}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_rmb` 收到 1 次命中。 +- b2log 中存在 1 条 `RequestLog`,且字段满足: + - `ai_input_tokens` = `100` + - `ai_output_tokens` = `50` + - `ai_total_tokens` = `150` + - `ai_cache_read_tokens` = `30` + - `ai_cache_write_tokens` = `20` + - `ai_cost_value` = `(100 - 30) * 100 + 30 * 50 + 20 * 150 + 50 * 200 = 21500` + - `ai_cost_currency` = `"RMB"` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" index f8e82fb4e..4c5d6caa1 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" @@ -11,7 +11,8 @@ - `ai_provider`、`ai_cost_value`、`ai_cost_currency` 在 RMB 配额场景正确输出; - `ai_retry_count` 在多 Key 重试时正确累加; - `ai_route_rule_hits`、`ai_cluster_key_names`、`ai_auth_hit_quota_plans` 与请求行为一致; -- `ai_auth_reject_reason`、`ai_auth_reject_quota_plans` 在配额拒绝场景正确输出。 +- `ai_auth_reject_reason`、`ai_auth_reject_quota_plans` 在配额拒绝场景正确输出; +- `ai_cache_read_tokens`、`ai_cache_write_tokens` 在 cache 计费场景正确输出。 ## 2. 运行模式 @@ -178,6 +179,7 @@ | TC-05 | Fallback 后 provider 与 cluster_key_names | 主 cluster 返回 502 触发 fallback,校验 `ai_provider` 为 fallback cluster 的 provider,且 `ai_cluster_key_names` 包含两个 cluster | | TC-06 | 流式响应字段 | SSE 请求下校验 `ai_stream`、`ai_ttft_us`、`ai_tpot_us` | | TC-07 | 限流命中字段 | 触发 RPM/TPM 限流,校验 `ai_rate_limit_hits` | +| TC-08 | Cache Token 字段 | 后端返回含 `cache_read_tokens` / `cache_write_tokens` 的 usage,校验访问日志中 `ai_cache_read_tokens`、`ai_cache_write_tokens` 与 cache-aware 成本 | ## 7. 公共基础设施改造 From 1007fe78f7fe192057b8916ded7f8c523dbe9901 Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Sat, 22 Aug 2026 10:45:38 +0800 Subject: [PATCH 04/18] feat: support audio token RMB billing and update sys_design docs Signed-off-by: zhangmiao --- bfe_basic/request_ai_basic.go | 20 +- .../cluster_conf/cluster_conf_load.go | 19 +- .../cluster_conf/cluster_conf_load_test.go | 31 ++ bfe_modules/mod_access_pb3/request_log.go | 6 + .../mod_access_pb3/request_log_test.go | 8 + .../mod_ai_token_auth/mod_ai_token_auth.go | 73 ++- .../mod_ai_token_auth_test.go | 237 +++++++++ bfe_modules/mod_body_process/body_process.go | 18 +- .../mod_body_process/content_quota_usage.go | 4 + .../content_quota_usage_test.go | 29 ++ bfe_modules/mod_body_process/llm_util.go | 30 +- bfe_modules/mod_body_process/llm_util_test.go | 23 + .../design-changes.md | 486 ++++++++++++++++++ docs/zh_cn/sys_design/ai_access_log_fields.md | 32 +- docs/zh_cn/sys_design/rmb_quota.md | 153 +++++- go.mod | 2 +- go.sum | 4 +- .../sc03_rmb_quota_test.go | 110 +++- .../sc05_access_log_ai_fields_test.go | 79 ++- ...01\345\274\217\350\256\241\350\264\271.md" | 85 +++ ...01\345\274\217\350\256\241\350\264\271.md" | 75 +++ ...72\346\231\257\350\257\264\346\230\216.md" | 27 +- ...09-Audio Token\345\255\227\346\256\265.md" | 91 ++++ ...72\346\231\257\350\257\264\346\230\216.md" | 4 +- 24 files changed, 1558 insertions(+), 88 deletions(-) create mode 100644 docs/zh_cn/modifications/2026-08-22-audio-token-billing-support/design-changes.md create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-09-Audio\346\250\241\345\236\213\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-10-Audio\346\250\241\345\236\213\346\265\201\345\274\217\350\256\241\350\264\271.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-09-Audio Token\345\255\227\346\256\265.md" diff --git a/bfe_basic/request_ai_basic.go b/bfe_basic/request_ai_basic.go index b65e3b397..a10b3c045 100644 --- a/bfe_basic/request_ai_basic.go +++ b/bfe_basic/request_ai_basic.go @@ -32,12 +32,14 @@ const ( ) type TokenUsage struct { - PromptTokens int64 // number of tokens in the prompt (includes cache_read_tokens) - CompletionTokens int64 // number of tokens in the completion - CacheReadTokens int64 // usage.cache_read_tokens, already included in PromptTokens - CacheWriteTokens int64 // usage.cache_write_tokens, independent add-on item - UsedQuota int64 // used quota for this request (unit=total_token) - UsedCost int64 // used RMB cost for this request, 1 unit = 1e-8 yuan (unit=RMB) + PromptTokens int64 // number of tokens in the prompt (includes cache_read_tokens, audio_input_tokens) + CompletionTokens int64 // number of tokens in the completion (includes audio_output_tokens) + CacheReadTokens int64 // usage.cache_read_tokens, already included in PromptTokens + CacheWriteTokens int64 // usage.cache_write_tokens, independent add-on item + AudioInputTokens int64 // usage.audio_input_tokens, already included in PromptTokens + AudioOutputTokens int64 // usage.audio_output_tokens, already included in CompletionTokens + UsedQuota int64 // used quota for this request (unit=total_token) + UsedCost int64 // used RMB cost for this request, 1 unit = 1e-8 yuan (unit=RMB) } type TokenTimeInfo struct { @@ -65,9 +67,9 @@ type AiBasicInfo struct { ClientKeyId string ClientModel string TargetModel string - Provider string // upstream model provider, e.g. openai, deepseek - RetryCount uint32 // model invocation retry count (key-level retry) - CostCurrency string // cost currency, e.g. RMB, USD + Provider string // upstream model provider, e.g. openai, deepseek + RetryCount uint32 // model invocation retry count (key-level retry) + CostCurrency string // cost currency, e.g. RMB, USD tokenUsage TokenUsage ApikeyTags []ApikeyTag TokenTimeInfo TokenTimeInfo diff --git a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go index 7f44dac7b..8b8c6668d 100644 --- a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go +++ b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go @@ -60,7 +60,7 @@ const ( const ( BalanceModeWrr = "WRR" // weighted round robin BalanceModeWlc = "WLC" // weighted least connection - BalanceModeEPP = "EPP" // balance by epp + BalanceModeEPP = "EPP" // balance by epp ) const ( @@ -157,7 +157,7 @@ type ModelPrice struct { // ModelTable represents the cost/pricing table for a cluster type ModelTable struct { - Currency string // fixed "RMB" in v0.4 + Currency string // fixed "RMB" in v0.4 Models []ModelPrice // priceIndex is built at config load time: model -> mode -> *ModelPrice @@ -185,11 +185,15 @@ const ( PriceOutputCostPerToken = "output_cost_per_token" PriceCacheReadInputTokenCost = "cache_read_input_token_cost" PriceCacheCreationInputTokenCost = "cache_creation_input_token_cost" + PriceInputCostPerAudioToken = "input_cost_per_audio_token" + PriceOutputCostPerAudioToken = "output_cost_per_audio_token" PriceInputCostPerTokenInt = "input_cost_per_token_int" PriceOutputCostPerTokenInt = "output_cost_per_token_int" PriceCacheReadInputTokenCostInt = "cache_read_input_token_cost_int" PriceCacheCreationInputTokenCostInt = "cache_creation_input_token_cost_int" + PriceInputCostPerAudioTokenInt = "input_cost_per_audio_token_int" + PriceOutputCostPerAudioTokenInt = "output_cost_per_audio_token_int" ) func (conf *BackendHTTPS) GetProtocol() string { @@ -267,7 +271,7 @@ type GslbBasicConf struct { RetryMax *int // inner cluster retry HashConf *HashConf - BalanceMode *string // balanceMode, default WRR + BalanceMode *string // balanceMode, default WRR EPPAddr *[]string // EPP address } @@ -293,7 +297,7 @@ type ClusterConf struct { GslbBasic *GslbBasicConf // gslb basic conf for cluster ClusterBasic *ClusterBasicConf // basic conf for cluster HTTPSConf *BackendHTTPS // backend's https conf - AIConf *AIConf // ai conf for cluster + AIConf *AIConf // ai conf for cluster } type ClusterToConf map[string]ClusterConf @@ -810,7 +814,10 @@ func ModelTableCheck(table *ModelTable) error { output := price.Prices[PriceOutputCostPerToken] cacheRead := price.Prices[PriceCacheReadInputTokenCost] cacheWrite := price.Prices[PriceCacheCreationInputTokenCost] - if input < 0 || output < 0 || cacheRead < 0 || cacheWrite < 0 { + audioInput := price.Prices[PriceInputCostPerAudioToken] + audioOutput := price.Prices[PriceOutputCostPerAudioToken] + if input < 0 || output < 0 || cacheRead < 0 || cacheWrite < 0 || + audioInput < 0 || audioOutput < 0 { return fmt.Errorf("negative price for model %s", price.Model) } @@ -818,6 +825,8 @@ func ModelTableCheck(table *ModelTable) error { price.Prices[PriceOutputCostPerTokenInt] = float64(quota.RmbToFixedPoint(output)) price.Prices[PriceCacheReadInputTokenCostInt] = float64(quota.RmbToFixedPoint(cacheRead)) price.Prices[PriceCacheCreationInputTokenCostInt] = float64(quota.RmbToFixedPoint(cacheWrite)) + price.Prices[PriceInputCostPerAudioTokenInt] = float64(quota.RmbToFixedPoint(audioInput)) + price.Prices[PriceOutputCostPerAudioTokenInt] = float64(quota.RmbToFixedPoint(audioOutput)) if table.priceIndex[price.Model] == nil { table.priceIndex[price.Model] = make(map[string]*ModelPrice) diff --git a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go index 6ea17f6ce..c7909d669 100644 --- a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go +++ b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go @@ -183,6 +183,37 @@ func TestModelTableCheck(t *testing.T) { } }) + t.Run("valid RMB table with audio prices", func(t *testing.T) { + table := &ModelTable{ + Currency: "RMB", + Models: []ModelPrice{ + { + Model: "gpt-audio-1.5", + Mode: "chat", + Prices: map[string]float64{ + PriceInputCostPerToken: 0.00000178, + PriceOutputCostPerToken: 0.00000715, + PriceInputCostPerAudioToken: 0.00002288, + PriceOutputCostPerAudioToken: 0.00004576, + }, + }, + }, + } + if err := ModelTableCheck(table); err != nil { + t.Fatalf("ModelTableCheck failed: %v", err) + } + entry := LookupModelPrice(table, "gpt-audio-1.5", "chat") + if entry == nil { + t.Fatal("LookupModelPrice should return entry") + } + if entry.Prices[PriceInputCostPerAudioTokenInt] != 2288 { + t.Errorf("audio input cost int = %v, want 2288", entry.Prices[PriceInputCostPerAudioTokenInt]) + } + if entry.Prices[PriceOutputCostPerAudioTokenInt] != 4576 { + t.Errorf("audio output cost int = %v, want 4576", entry.Prices[PriceOutputCostPerAudioTokenInt]) + } + }) + t.Run("invalid currency", func(t *testing.T) { table := &ModelTable{ Currency: "USD", diff --git a/bfe_modules/mod_access_pb3/request_log.go b/bfe_modules/mod_access_pb3/request_log.go index fd8d54842..1437de077 100644 --- a/bfe_modules/mod_access_pb3/request_log.go +++ b/bfe_modules/mod_access_pb3/request_log.go @@ -415,6 +415,12 @@ func reqAiInfoGen(reqLog *bfe_access_pb3.RequestLog, req *bfe_basic.Request, res if usage.CacheWriteTokens > 0 { reqLog.AiCacheWriteTokens = proto.Int64(usage.CacheWriteTokens) } + if usage.AudioInputTokens > 0 { + reqLog.AiAudioInputTokens = proto.Int64(usage.AudioInputTokens) + } + if usage.AudioOutputTokens > 0 { + reqLog.AiAudioOutputTokens = proto.Int64(usage.AudioOutputTokens) + } if usage.UsedCost > 0 { reqLog.AiCostValue = proto.Int64(usage.UsedCost) } diff --git a/bfe_modules/mod_access_pb3/request_log_test.go b/bfe_modules/mod_access_pb3/request_log_test.go index eca5e1d62..4d1c0e048 100644 --- a/bfe_modules/mod_access_pb3/request_log_test.go +++ b/bfe_modules/mod_access_pb3/request_log_test.go @@ -376,6 +376,8 @@ func TestReqAiInfoGen(t *testing.T) { usage.UsedQuota = 30 usage.CacheReadTokens = 5 usage.CacheWriteTokens = 2 + usage.AudioInputTokens = 3 + usage.AudioOutputTokens = 4 usage.UsedCost = 5000 req.SetContext(bfe_basic.REQ_AI_BASIC_CONTEXT, aiInfo) @@ -424,6 +426,12 @@ func TestReqAiInfoGen(t *testing.T) { if reqLog.AiCacheWriteTokens == nil || *reqLog.AiCacheWriteTokens != 2 { t.Errorf("AiCacheWriteTokens error, got: %v", reqLog.AiCacheWriteTokens) } + if reqLog.AiAudioInputTokens == nil || *reqLog.AiAudioInputTokens != 3 { + t.Errorf("AiAudioInputTokens error, got: %v", reqLog.AiAudioInputTokens) + } + if reqLog.AiAudioOutputTokens == nil || *reqLog.AiAudioOutputTokens != 4 { + t.Errorf("AiAudioOutputTokens error, got: %v", reqLog.AiAudioOutputTokens) + } if reqLog.AiCostValue == nil || *reqLog.AiCostValue != 5000 { t.Error("AiCostValue error") } diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go index fb5b44c15..01eaba7e3 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go @@ -112,13 +112,15 @@ func (m *ModuleAITokenAuth) matchTokenRule(req *bfe_basic.Request) bool { } func UpdateCtxByUsage(ctx *TokenAuthContext, data []byte) { - var used, prompt, completion, cacheRead, cacheWrite int64 + var used, prompt, completion, cacheRead, cacheWrite, audioInput, audioOutput int64 used = gjson.GetBytes(data, "usage.total_tokens").Int() prompt = gjson.GetBytes(data, "usage.prompt_tokens").Int() completion = gjson.GetBytes(data, "usage.completion_tokens").Int() cacheRead = gjson.GetBytes(data, "usage.cache_read_tokens").Int() cacheWrite = gjson.GetBytes(data, "usage.cache_write_tokens").Int() + audioInput = gjson.GetBytes(data, "usage.audio_input_tokens").Int() + audioOutput = gjson.GetBytes(data, "usage.audio_output_tokens").Int() tokenUsage := ctx.aiBasicInfo.GetTokenUsage() if used > 0 { @@ -127,12 +129,16 @@ func UpdateCtxByUsage(ctx *TokenAuthContext, data []byte) { tokenUsage.CompletionTokens = completion tokenUsage.CacheReadTokens = cacheRead tokenUsage.CacheWriteTokens = cacheWrite + tokenUsage.AudioInputTokens = audioInput + tokenUsage.AudioOutputTokens = audioOutput } else if prompt > 0 || completion > 0 { tokenUsage.UsedQuota = prompt + completion tokenUsage.PromptTokens = prompt tokenUsage.CompletionTokens = completion tokenUsage.CacheReadTokens = cacheRead tokenUsage.CacheWriteTokens = cacheWrite + tokenUsage.AudioInputTokens = audioInput + tokenUsage.AudioOutputTokens = audioOutput } } @@ -454,8 +460,10 @@ func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe completionTokens := usage.CompletionTokens cacheReadTokens := usage.CacheReadTokens cacheWriteTokens := usage.CacheWriteTokens + audioInputTokens := usage.AudioInputTokens + audioOutputTokens := usage.AudioOutputTokens - // sanitize cache usage to avoid negative normal input or negative cache charges + // sanitize sub-token usage to avoid negative normal input/output or negative charges if cacheReadTokens < 0 { cacheReadTokens = 0 } @@ -465,23 +473,74 @@ func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe if cacheWriteTokens < 0 { cacheWriteTokens = 0 } + if audioInputTokens < 0 { + audioInputTokens = 0 + } + if audioInputTokens > promptTokens-cacheReadTokens { + audioInputTokens = promptTokens - cacheReadTokens + } + if audioOutputTokens < 0 { + audioOutputTokens = 0 + } + if audioOutputTokens > completionTokens { + audioOutputTokens = completionTokens + } cacheReadCost := int64(entry.Prices[cluster_conf.PriceCacheReadInputTokenCostInt]) cacheWriteCost := int64(entry.Prices[cluster_conf.PriceCacheCreationInputTokenCostInt]) + audioInputCost := int64(entry.Prices[cluster_conf.PriceInputCostPerAudioTokenInt]) + audioOutputCost := int64(entry.Prices[cluster_conf.PriceOutputCostPerAudioTokenInt]) - var cost int64 + // normal input/output start as the full totals + normalInput := promptTokens + normalOutput := completionTokens + + // cache-aware billing: split cache read from prompt if cacheReadCost > 0 || cacheWriteCost > 0 { - // cache-aware billing: split prompt into normal input and cache read - normalInput := promptTokens - cacheReadTokens + normalInput = promptTokens - cacheReadTokens if normalInput < 0 { normalInput = 0 } + } + + // audio-aware billing: split audio input from normal input + if audioInputCost > 0 { + if audioInputTokens > normalInput { + audioInputTokens = normalInput + } + normalInput = normalInput - audioInputTokens + if normalInput < 0 { + normalInput = 0 + } + } else { + // no audio input price configured: bill audio input as normal input + audioInputTokens = 0 + } + + // audio-aware billing: split audio output from completion + if audioOutputCost > 0 { + if audioOutputTokens > completionTokens { + audioOutputTokens = completionTokens + } + normalOutput = completionTokens - audioOutputTokens + if normalOutput < 0 { + normalOutput = 0 + } + } else { + // no audio output price configured: bill audio output as normal output + audioOutputTokens = 0 + } + + var cost int64 + if cacheReadCost > 0 || cacheWriteCost > 0 || audioInputCost > 0 || audioOutputCost > 0 { cost = normalInput*inputCost + cacheReadTokens*cacheReadCost + cacheWriteTokens*cacheWriteCost + - completionTokens*outputCost + audioInputTokens*audioInputCost + + normalOutput*outputCost + + audioOutputTokens*audioOutputCost } else { - // fallback to legacy billing when no cache price is configured + // fallback to legacy billing when no cache/audio price is configured cost = promptTokens*inputCost + completionTokens*outputCost } diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go index 2b7161481..d0fb81964 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go @@ -15,6 +15,7 @@ package mod_ai_token_auth import ( + "bytes" "fmt" "io/ioutil" "net" @@ -731,6 +732,10 @@ func buildTestClusterConf(model string, inputCost, outputCost float64) *bfe_clus } func buildTestClusterConfWithCache(model string, inputCost, outputCost, cacheReadCost, cacheWriteCost float64) *bfe_cluster.BfeCluster { + return buildTestClusterConfWithAudio(model, inputCost, outputCost, cacheReadCost, cacheWriteCost, 0, 0) +} + +func buildTestClusterConfWithAudio(model string, inputCost, outputCost, cacheReadCost, cacheWriteCost, audioInputCost, audioOutputCost float64) *bfe_cluster.BfeCluster { modelTable := &cluster_conf.ModelTable{ Currency: "RMB", Models: []cluster_conf.ModelPrice{ @@ -743,6 +748,8 @@ func buildTestClusterConfWithCache(model string, inputCost, outputCost, cacheRea cluster_conf.PriceOutputCostPerToken: outputCost, cluster_conf.PriceCacheReadInputTokenCost: cacheReadCost, cluster_conf.PriceCacheCreationInputTokenCost: cacheWriteCost, + cluster_conf.PriceInputCostPerAudioToken: audioInputCost, + cluster_conf.PriceOutputCostPerAudioToken: audioOutputCost, }, }, }, @@ -1021,6 +1028,236 @@ func TestCalcCostUnits_CacheReadExceedsPrompt(t *testing.T) { } } +func TestCalcCostUnits_Audio(t *testing.T) { + m := NewModuleAITokenAuth() + clusterName := "audio-backup" + model := "gpt-audio-1.5" + req := newTestRequestWithCluster("ak-123", "AI_product", clusterName, model) + cluster := buildTestClusterConfWithAudio(model, 0.00000178, 0.00000715, 0, 0, 0.00002288, 0.00004576) + req.SvrDataConf = &mockServerDataConf{clusters: map[string]*bfe_cluster.BfeCluster{clusterName: cluster}} + + usage := &bfe_basic.TokenUsage{ + PromptTokens: 4000, + CompletionTokens: 500, + AudioInputTokens: 1000, + AudioOutputTokens: 200, + } + + expectedCost := int64(3951700) + got := m.calcCostUnits(req, req.SvrDataConf, usage) + if got != expectedCost { + t.Errorf("expected cost %d, got %d", expectedCost, got) + } +} + +func TestCalcCostUnits_AudioFallback(t *testing.T) { + m := NewModuleAITokenAuth() + clusterName := "deepseek-backup" + model := "deepseek-v4-flash" + req := newTestRequestWithCluster("ak-123", "AI_product", clusterName, model) + cluster := buildTestClusterConf(model, 0.000003, 0.000009) + req.SvrDataConf = &mockServerDataConf{clusters: map[string]*bfe_cluster.BfeCluster{clusterName: cluster}} + + usage := &bfe_basic.TokenUsage{ + PromptTokens: 100, + CompletionTokens: 200, + AudioInputTokens: 30, + AudioOutputTokens: 20, + } + + // No audio prices configured, should fallback to legacy formula. + expectedCost := quota.RmbToFixedPoint(100*0.000003 + 200*0.000009) + got := m.calcCostUnits(req, req.SvrDataConf, usage) + if got != expectedCost { + t.Errorf("expected fallback cost %d, got %d", expectedCost, got) + } +} + +func TestCalcCostUnits_AudioInputExceedsPrompt(t *testing.T) { + m := NewModuleAITokenAuth() + clusterName := "audio-backup" + model := "gpt-audio-1.5" + req := newTestRequestWithCluster("ak-123", "AI_product", clusterName, model) + cluster := buildTestClusterConfWithAudio(model, 0.00000178, 0.00000715, 0, 0, 0.00002288, 0.00004576) + req.SvrDataConf = &mockServerDataConf{clusters: map[string]*bfe_cluster.BfeCluster{clusterName: cluster}} + + usage := &bfe_basic.TokenUsage{ + PromptTokens: 4000, + CompletionTokens: 500, + AudioInputTokens: 5000, // exceeds prompt, should be truncated + AudioOutputTokens: 200, + } + + // normal_input = 0 after truncation, audio_input = 4000 + expectedCost := int64(4000*2288 + 300*715 + 200*4576) + got := m.calcCostUnits(req, req.SvrDataConf, usage) + if got != expectedCost { + t.Errorf("expected truncated cost %d, got %d", expectedCost, got) + } +} + +func TestCalcCostUnits_AudioOutputExceedsCompletion(t *testing.T) { + m := NewModuleAITokenAuth() + clusterName := "audio-backup" + model := "gpt-audio-1.5" + req := newTestRequestWithCluster("ak-123", "AI_product", clusterName, model) + cluster := buildTestClusterConfWithAudio(model, 0.00000178, 0.00000715, 0, 0, 0.00002288, 0.00004576) + req.SvrDataConf = &mockServerDataConf{clusters: map[string]*bfe_cluster.BfeCluster{clusterName: cluster}} + + usage := &bfe_basic.TokenUsage{ + PromptTokens: 4000, + CompletionTokens: 500, + AudioInputTokens: 1000, + AudioOutputTokens: 800, // exceeds completion, should be truncated + } + + // normal_output = 0 after truncation, audio_output = 500 + expectedCost := int64(3000*178 + 1000*2288 + 500*4576) + got := m.calcCostUnits(req, req.SvrDataConf, usage) + if got != expectedCost { + t.Errorf("expected truncated cost %d, got %d", expectedCost, got) + } +} + +func TestCalcCostUnits_CacheAndAudio(t *testing.T) { + m := NewModuleAITokenAuth() + clusterName := "multi-backup" + model := "claude-audio-4-6" + req := newTestRequestWithCluster("ak-123", "AI_product", clusterName, model) + cluster := buildTestClusterConfWithAudio(model, 0.00000452, 0.00002262, 0.00000045, 0.00000565, 0.00002288, 0.00004576) + req.SvrDataConf = &mockServerDataConf{clusters: map[string]*bfe_cluster.BfeCluster{clusterName: cluster}} + + usage := &bfe_basic.TokenUsage{ + PromptTokens: 8000, + CompletionTokens: 500, + CacheReadTokens: 3000, + CacheWriteTokens: 1000, + AudioInputTokens: 1000, + AudioOutputTokens: 200, + } + + // normal_input = 8000 - 3000 - 1000 = 4000 + // normal_output = 500 - 200 = 300 + expectedCost := int64(4000*452 + 3000*45 + 1000*565 + 1000*2288 + 300*2262 + 200*4576) + got := m.calcCostUnits(req, req.SvrDataConf, usage) + if got != expectedCost { + t.Errorf("expected cost %d, got %d", expectedCost, got) + } +} + +func TestUpdateCtxByUsage_Audio(t *testing.T) { + req := newTestRequest("", "AI_product") + ai := req.InitAiBasicInfo() + ctx := &TokenAuthContext{aiBasicInfo: ai} + + data := []byte(`{"usage":{"total_tokens":4500,"prompt_tokens":4000,"completion_tokens":500,"audio_input_tokens":1000,"audio_output_tokens":200}}`) + UpdateCtxByUsage(ctx, data) + + usage := ai.GetTokenUsage() + if usage.UsedQuota != 4500 { + t.Errorf("expected UsedQuota 4500, got %d", usage.UsedQuota) + } + if usage.PromptTokens != 4000 { + t.Errorf("expected PromptTokens 4000, got %d", usage.PromptTokens) + } + if usage.CompletionTokens != 500 { + t.Errorf("expected CompletionTokens 500, got %d", usage.CompletionTokens) + } + if usage.AudioInputTokens != 1000 { + t.Errorf("expected AudioInputTokens 1000, got %d", usage.AudioInputTokens) + } + if usage.AudioOutputTokens != 200 { + t.Errorf("expected AudioOutputTokens 200, got %d", usage.AudioOutputTokens) + } +} + +func TestTokenRequestFinishHandler_RMB_Audio_NonStreaming(t *testing.T) { + m := NewModuleAITokenAuth() + client := newMockRedisClient() + m.redisClient = client + + clusterName := "audio-backup" + model := "gpt-audio-1.5" + req := newTestRequestWithCluster("ak-123", "AI_product", clusterName, model) + + cluster := buildTestClusterConfWithAudio(model, 0.00000178, 0.00000715, 0, 0, 0.00002288, 0.00004576) + req.SvrDataConf = &mockServerDataConf{clusters: map[string]*bfe_cluster.BfeCluster{clusterName: cluster}} + + rmbPlan := &QuotaPlan{ + Id: "rmb-plan", + RedisKey: "QUOTA_AI_product-AudioNonStreaming", + Unit: "RMB", + Quota: 100000000, + } + SetTokenAuthContext(req, &Token{Key: "ak-123", KeyId: "ak-123-id", QuotaPlans: []*QuotaPlan{rmbPlan}}, 0, nil) + + res := &bfe_http.Response{StatusCode: 200, ContentLength: int64(len(`{"usage":{"prompt_tokens":4000,"completion_tokens":500,"audio_input_tokens":1000,"audio_output_tokens":200}}`))} + res.Body = ioutil.NopCloser(bytes.NewBufferString(`{"usage":{"prompt_tokens":4000,"completion_tokens":500,"audio_input_tokens":1000,"audio_output_tokens":200}}`)) + if ret := m.tokenReadResponseHandler(req, res); ret != bfe_module.BfeHandlerGoOn { + t.Fatalf("expected goon, got %d", ret) + } + + if ret := m.tokenRequestFinishHandler(req, res); ret != bfe_module.BfeHandlerGoOn { + t.Fatalf("expected goon, got %d", ret) + } + + usage := req.GetAiBasicInfo().GetTokenUsage() + expectedCost := int64(3951700) + if usage.UsedCost != expectedCost { + t.Errorf("expected UsedCost %d, got %d", expectedCost, usage.UsedCost) + } + + remaining := client.data[rmbPlan.RedisKey] + if remaining != rmbPlan.Quota-expectedCost { + t.Errorf("expected remaining %d, got %d", rmbPlan.Quota-expectedCost, remaining) + } +} + +func TestTokenRequestFinishHandler_RMB_Audio_Streaming(t *testing.T) { + m := NewModuleAITokenAuth() + client := newMockRedisClient() + m.redisClient = client + + clusterName := "audio-backup" + model := "gpt-audio-1.5" + req := newTestRequestWithCluster("ak-123", "AI_product", clusterName, model) + + cluster := buildTestClusterConfWithAudio(model, 0.00000178, 0.00000715, 0, 0, 0.00002288, 0.00004576) + req.SvrDataConf = &mockServerDataConf{clusters: map[string]*bfe_cluster.BfeCluster{clusterName: cluster}} + + rmbPlan := &QuotaPlan{ + Id: "rmb-plan", + RedisKey: "QUOTA_AI_product-AudioStreaming", + Unit: "RMB", + Quota: 100000000, + } + SetTokenAuthContext(req, &Token{Key: "ak-123", KeyId: "ak-123-id", QuotaPlans: []*QuotaPlan{rmbPlan}}, 0, nil) + + // Simulate streaming: mod_body_process has filled token usage (including audio fields). + ai := req.GetAiBasicInfo() + usage := ai.GetTokenUsage() + usage.PromptTokens = 4000 + usage.CompletionTokens = 500 + usage.AudioInputTokens = 1000 + usage.AudioOutputTokens = 200 + usage.UsedQuota = 4500 + + res := &bfe_http.Response{StatusCode: 200, ContentLength: -1} + if ret := m.tokenRequestFinishHandler(req, res); ret != bfe_module.BfeHandlerGoOn { + t.Fatalf("expected goon, got %d", ret) + } + + expectedCost := int64(3951700) + if usage.UsedCost != expectedCost { + t.Errorf("expected UsedCost %d, got %d", expectedCost, usage.UsedCost) + } + + remaining := client.data[rmbPlan.RedisKey] + if remaining != rmbPlan.Quota-expectedCost { + t.Errorf("expected remaining %d, got %d", rmbPlan.Quota-expectedCost, remaining) + } +} + func TestMonitorAndReloadHandlers(t *testing.T) { m := NewModuleAITokenAuth() mon := m.monitorHandlers() diff --git a/bfe_modules/mod_body_process/body_process.go b/bfe_modules/mod_body_process/body_process.go index 3738c119a..5b1cc260e 100644 --- a/bfe_modules/mod_body_process/body_process.go +++ b/bfe_modules/mod_body_process/body_process.go @@ -428,6 +428,8 @@ func (e *RawEvent) GetQuotaUsage() QuotaUsage { completion := gjson.GetBytes(*e, "usage.completion_tokens").Int() cacheRead := gjson.GetBytes(*e, "usage.cache_read_tokens").Int() cacheWrite := gjson.GetBytes(*e, "usage.cache_write_tokens").Int() + audioInput := gjson.GetBytes(*e, "usage.audio_input_tokens").Int() + audioOutput := gjson.GetBytes(*e, "usage.audio_output_tokens").Int() if used > 0 { isguess = false } else { @@ -435,13 +437,15 @@ func (e *RawEvent) GetQuotaUsage() QuotaUsage { } return QuotaUsage{ - PromptTokens: prompt, - CompletionTokens: completion, - CacheReadTokens: cacheRead, - CacheWriteTokens: cacheWrite, - UsedQuota: used, - CurrentTokens: curtoken, - IsGuess: isguess, + PromptTokens: prompt, + CompletionTokens: completion, + CacheReadTokens: cacheRead, + CacheWriteTokens: cacheWrite, + AudioInputTokens: audioInput, + AudioOutputTokens: audioOutput, + UsedQuota: used, + CurrentTokens: curtoken, + IsGuess: isguess, } } diff --git a/bfe_modules/mod_body_process/content_quota_usage.go b/bfe_modules/mod_body_process/content_quota_usage.go index bbdee99e3..934bb552d 100644 --- a/bfe_modules/mod_body_process/content_quota_usage.go +++ b/bfe_modules/mod_body_process/content_quota_usage.go @@ -55,6 +55,8 @@ func (caf *QuotaUsageProcessor) Process(events []Event) ([]Event, error) { tctx.PromptTokens = rquota.PromptTokens tctx.CacheReadTokens = rquota.CacheReadTokens tctx.CacheWriteTokens = rquota.CacheWriteTokens + tctx.AudioInputTokens = rquota.AudioInputTokens + tctx.AudioOutputTokens = rquota.AudioOutputTokens tctx.UsedQuota = rquota.UsedQuota } else if rquota.PromptTokens > 0 || rquota.CompletionTokens > 0 { tctx.UsedQuota = rquota.PromptTokens + rquota.CompletionTokens @@ -62,6 +64,8 @@ func (caf *QuotaUsageProcessor) Process(events []Event) ([]Event, error) { tctx.CompletionTokens = rquota.CompletionTokens tctx.CacheReadTokens = rquota.CacheReadTokens tctx.CacheWriteTokens = rquota.CacheWriteTokens + tctx.AudioInputTokens = rquota.AudioInputTokens + tctx.AudioOutputTokens = rquota.AudioOutputTokens } } } diff --git a/bfe_modules/mod_body_process/content_quota_usage_test.go b/bfe_modules/mod_body_process/content_quota_usage_test.go index 44f804a1c..c5164c19c 100644 --- a/bfe_modules/mod_body_process/content_quota_usage_test.go +++ b/bfe_modules/mod_body_process/content_quota_usage_test.go @@ -104,3 +104,32 @@ func TestQuotaUsageProcessorProcessWithCache(t *testing.T) { t.Errorf("expected CacheWriteTokens 1000, got %d", usage.CacheWriteTokens) } } + +func TestQuotaUsageProcessorProcessWithAudio(t *testing.T) { + req := newTestRequest("AI_product") + ai := req.InitAiBasicInfo() + res := &bfe_http.Response{StatusCode: bfe_http.StatusOK} + p := NewQuotaUsageProcessor(req, res) + + events := []Event{newRawEvent(`{"usage":{"total_tokens":4500,"prompt_tokens":4000,"completion_tokens":500,"audio_input_tokens":1000,"audio_output_tokens":200}}`)} + out, err := p.Process(events) + if err != nil { + t.Fatalf("Process failed: %s", err) + } + if len(out) != 1 { + t.Errorf("expected 1 event, got %d", len(out)) + } + usage := ai.GetTokenUsage() + if usage.UsedQuota != 4500 { + t.Errorf("expected UsedQuota 4500, got %d", usage.UsedQuota) + } + if usage.PromptTokens != 4000 || usage.CompletionTokens != 500 { + t.Errorf("unexpected prompt/completion: %+v", usage) + } + if usage.AudioInputTokens != 1000 { + t.Errorf("expected AudioInputTokens 1000, got %d", usage.AudioInputTokens) + } + if usage.AudioOutputTokens != 200 { + t.Errorf("expected AudioOutputTokens 200, got %d", usage.AudioOutputTokens) + } +} diff --git a/bfe_modules/mod_body_process/llm_util.go b/bfe_modules/mod_body_process/llm_util.go index 90c9b708e..c69320924 100644 --- a/bfe_modules/mod_body_process/llm_util.go +++ b/bfe_modules/mod_body_process/llm_util.go @@ -30,11 +30,13 @@ const UnknownModel = "unknown" type QuotaUsage struct { //return from reponse - PromptTokens int64 // number of tokens in the prompt - CompletionTokens int64 // number of tokens in the completion - CacheReadTokens int64 // usage.cache_read_tokens, already included in PromptTokens - CacheWriteTokens int64 // usage.cache_write_tokens, independent add-on item - UsedQuota int64 // used quota for this request + PromptTokens int64 // number of tokens in the prompt + CompletionTokens int64 // number of tokens in the completion + CacheReadTokens int64 // usage.cache_read_tokens, already included in PromptTokens + CacheWriteTokens int64 // usage.cache_write_tokens, independent add-on item + AudioInputTokens int64 // usage.audio_input_tokens, already included in PromptTokens + AudioOutputTokens int64 // usage.audio_output_tokens, already included in CompletionTokens + UsedQuota int64 // used quota for this request //estimate for current response CurrentTokens int64 //effect when IsGuess is true @@ -124,6 +126,8 @@ func (e *SSEEvent) GetQuotaUsage() QuotaUsage { completion := gjson.GetBytes(data, "usage.completion_tokens").Int() cacheRead := gjson.GetBytes(data, "usage.cache_read_tokens").Int() cacheWrite := gjson.GetBytes(data, "usage.cache_write_tokens").Int() + audioInput := gjson.GetBytes(data, "usage.audio_input_tokens").Int() + audioOutput := gjson.GetBytes(data, "usage.audio_output_tokens").Int() curtoken := int64(0) isguess := true @@ -134,13 +138,15 @@ func (e *SSEEvent) GetQuotaUsage() QuotaUsage { } return QuotaUsage{ - PromptTokens: prompt, - CompletionTokens: completion, - CacheReadTokens: cacheRead, - CacheWriteTokens: cacheWrite, - UsedQuota: used, - CurrentTokens: curtoken, - IsGuess: isguess, + PromptTokens: prompt, + CompletionTokens: completion, + CacheReadTokens: cacheRead, + CacheWriteTokens: cacheWrite, + AudioInputTokens: audioInput, + AudioOutputTokens: audioOutput, + UsedQuota: used, + CurrentTokens: curtoken, + IsGuess: isguess, } } diff --git a/bfe_modules/mod_body_process/llm_util_test.go b/bfe_modules/mod_body_process/llm_util_test.go index 5bc96c2fc..9eb0ae432 100644 --- a/bfe_modules/mod_body_process/llm_util_test.go +++ b/bfe_modules/mod_body_process/llm_util_test.go @@ -98,6 +98,29 @@ func TestSSEEventGetQuotaUsage(t *testing.T) { } } +func TestSSEEventGetQuotaUsageWithAudio(t *testing.T) { + ev := &SSEEvent{DataLines: [][]byte{[]byte(`{"usage":{"total_tokens":4500,"prompt_tokens":4000,"completion_tokens":500,"audio_input_tokens":1000,"audio_output_tokens":200}}`)}} + q := ev.GetQuotaUsage() + if q.UsedQuota != 4500 { + t.Errorf("expected UsedQuota 4500, got %d", q.UsedQuota) + } + if q.PromptTokens != 4000 { + t.Errorf("expected PromptTokens 4000, got %d", q.PromptTokens) + } + if q.CompletionTokens != 500 { + t.Errorf("expected CompletionTokens 500, got %d", q.CompletionTokens) + } + if q.AudioInputTokens != 1000 { + t.Errorf("expected AudioInputTokens 1000, got %d", q.AudioInputTokens) + } + if q.AudioOutputTokens != 200 { + t.Errorf("expected AudioOutputTokens 200, got %d", q.AudioOutputTokens) + } + if q.IsGuess { + t.Error("expected IsGuess false") + } +} + func TestSSEEventSetJsonField(t *testing.T) { ev := &SSEEvent{DataLines: [][]byte{[]byte(`{"text":"hello"}`)}} if err := ev.SetJsonField("text", "world"); err != nil { diff --git a/docs/zh_cn/modifications/2026-08-22-audio-token-billing-support/design-changes.md b/docs/zh_cn/modifications/2026-08-22-audio-token-billing-support/design-changes.md new file mode 100644 index 000000000..35797c238 --- /dev/null +++ b/docs/zh_cn/modifications/2026-08-22-audio-token-billing-support/design-changes.md @@ -0,0 +1,486 @@ +# BFE 支持音频 Token 计费能力扩展 + +## 1. 背景 + +AI 网关场景下,部分音频模型(如 `gpt-audio-1.5`)的 usage 中携带 `audio_input_tokens` 和 `audio_output_tokens`,需按音频规则分别计费: + +- `audio_input_tokens` **已包含**在 `prompt_tokens` 中,计算普通 input 时需要剥离; +- `audio_output_tokens` **已包含**在 `completion_tokens` 中,计算普通 output 时需要剥离; +- 普通 input 计费基数为 `prompt_tokens - cache_read_tokens - audio_input_tokens`; +- 普通 output 计费基数为 `completion_tokens - audio_output_tokens`。 + +当前 BFE 已支持 cache 计费拆分,但尚未支持音频 token 从 input/output 双向剥离计费。本变更在不破坏现有 RMB / total_token / cache 计费逻辑的前提下,扩展 BFE 对音频 token 计费的端到端支持。 + +--- + +## 2. 需求示例 + +参考示例 4 的模型与 usage: + +| 价格字段 | USD / token | +|---|---| +| `input_cost_per_token` | `1.7875e-06` | +| `output_cost_per_token` | `7.15e-06` | +| `input_cost_per_audio_token` | `2.288e-05` | +| `output_cost_per_audio_token` | `4.576e-05` | + +| Usage 字段 | 值 | +|---|---| +| `prompt_tokens` | 4000 | +| `audio_input_tokens` | 1000 | +| `completion_tokens` | 500 | +| `audio_output_tokens` | 200 | + +计费公式: + +``` +normal_input = prompt_tokens - audio_input_tokens = 3000 +normal_output = completion_tokens - audio_output_tokens = 300 + +cost = normal_input × input_cost_per_token + + audio_input_tokens × input_cost_per_audio_token + + normal_output × output_cost_per_token + + audio_output_tokens × output_cost_per_audio_token + = 0.0395395 USD +``` + +--- + +## 3. 当前现状 + +| 层级 | 当前能力 | 不足 | +|---|---|---| +| 数据结构 | `bfe_basic.TokenUsage` 已含 `PromptTokens`、`CompletionTokens`、`CacheReadTokens`、`CacheWriteTokens`、`UsedQuota`、`UsedCost` | 缺少音频字段 | +| 价格配置 | `ModelPrice.Prices` 已识别文本与 cache 单价 | 缺少音频单价 | +| Usage 解析 | 非流式 `UpdateCtxByUsage`、流式 `SSEEvent.GetQuotaUsage` / `QuotaUsageProcessor.Process` 已解析 cache 字段 | 不识别 `audio_input_tokens`、`audio_output_tokens` | +| 费用计算 | `calcCostUnits` 已支持 cache 拆分计费 | 无法拆分音频 input/output 计费 | +| 访问日志 | `RequestLog` 已有文本与 cache 明细 | 缺少音频明细 | + +--- + +## 4. 变更目标 + +1. 在 `TokenUsage` 中增加 `AudioInputTokens`、`AudioOutputTokens`。 +2. 在模型价格表中支持 `input_cost_per_audio_token`、`output_cost_per_audio_token`。 +3. 改造 usage 解析、费用计算、访问日志三个环节,识别并正确拆分音频 token 计费。 +4. 未配置音频单价时退化到原有逻辑,保持向后兼容。 +5. 保持 `UsedQuota`(total_token 维度)为 `prompt_tokens + completion_tokens`,与现有配额机制对齐。 +6. 补充单元测试与集成测试。 + +--- + +## 5. 变更总览 + +| 模块 | 主要改动 | +|---|---| +| `bfe_basic` | `TokenUsage` 增加 `AudioInputTokens`、`AudioOutputTokens` | +| `bfe_config/bfe_cluster_conf/cluster_conf` | 增加音频价格常量,`ModelTableCheck` 识别并转换音频单价为定点整数 | +| `mod_ai_token_auth` | `UpdateCtxByUsage` 解析音频 usage;`calcCostUnits` 按音频拆分公式计费 | +| `mod_body_process` | `SSEEvent.GetQuotaUsage`、`RawEvent.GetQuotaUsage`、`QuotaUsageProcessor.Process` 解析并累积音频 usage | +| `bfe-access-pb` / `mod_access_pb3` | 访问日志新增 `ai_audio_input_tokens`、`ai_audio_output_tokens`(可选但建议) | +| 测试 | 补充音频计费、流式/非流式、无音频单价退化、音频 token 超边界等测试 | + +--- + +## 6. 详细设计 + +### 6.1 扩展 `TokenUsage` + +**文件:** `bfe/bfe_basic/request_ai_basic.go` + +```go +type TokenUsage struct { + PromptTokens int64 // 含 cache_read、audio_input + CompletionTokens int64 // 含 audio_output + CacheReadTokens int64 // usage.cache_read_tokens,已包含在 PromptTokens 中 + CacheWriteTokens int64 // usage.cache_write_tokens,独立加项 + AudioInputTokens int64 // usage.audio_input_tokens,已包含在 PromptTokens 中 + AudioOutputTokens int64 // usage.audio_output_tokens,已包含在 CompletionTokens 中 + UsedQuota int64 // unit=total_token + UsedCost int64 // unit=RMB,1 unit = 1e-8 yuan +} +``` + +说明: +- `UsedQuota` 保持为 `prompt_tokens + completion_tokens`,用于 `total_token` 配额扣减。 +- RMB 计费使用 `PromptTokens - CacheReadTokens - AudioInputTokens` 作为普通 input,`CompletionTokens - AudioOutputTokens` 作为普通 output,避免音频 token 被按文本价格重复计费。 +- 与 cache 共存时,按 `prompt - cache_read - audio_input` 顺序剥离。若后端语义有重叠,需 backend 提供_disjoint_ 明细或后续调整策略。 + +### 6.2 扩展价格配置 + +**文件:** `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` + +新增常量: + +```go +const ( + PriceInputCostPerToken = "input_cost_per_token" + PriceOutputCostPerToken = "output_cost_per_token" + PriceCacheReadInputTokenCost = "cache_read_input_token_cost" + PriceCacheCreationInputTokenCost = "cache_creation_input_token_cost" + PriceInputCostPerAudioToken = "input_cost_per_audio_token" + PriceOutputCostPerAudioToken = "output_cost_per_audio_token" + + PriceInputCostPerTokenInt = "input_cost_per_token_int" + PriceOutputCostPerTokenInt = "output_cost_per_token_int" + PriceCacheReadInputTokenCostInt = "cache_read_input_token_cost_int" + PriceCacheCreationInputTokenCostInt = "cache_creation_input_token_cost_int" + PriceInputCostPerAudioTokenInt = "input_cost_per_audio_token_int" + PriceOutputCostPerAudioTokenInt = "output_cost_per_audio_token_int" +) +``` + +在 `ModelTableCheck` 中增加音频单价的读取与定点转换: + +```go +input := price.Prices[PriceInputCostPerToken] +output := price.Prices[PriceOutputCostPerToken] +cacheRead := price.Prices[PriceCacheReadInputTokenCost] +cacheWrite := price.Prices[PriceCacheCreationInputTokenCost] +audioInput := price.Prices[PriceInputCostPerAudioToken] +audioOutput := price.Prices[PriceOutputCostPerAudioToken] + +if input < 0 || output < 0 || cacheRead < 0 || cacheWrite < 0 || + audioInput < 0 || audioOutput < 0 { + return fmt.Errorf("negative price for model %s", price.Model) +} + +price.Prices[PriceInputCostPerTokenInt] = float64(quota.RmbToFixedPoint(input)) +price.Prices[PriceOutputCostPerTokenInt] = float64(quota.RmbToFixedPoint(output)) +price.Prices[PriceCacheReadInputTokenCostInt] = float64(quota.RmbToFixedPoint(cacheRead)) +price.Prices[PriceCacheCreationInputTokenCostInt] = float64(quota.RmbToFixedPoint(cacheWrite)) +price.Prices[PriceInputCostPerAudioTokenInt] = float64(quota.RmbToFixedPoint(audioInput)) +price.Prices[PriceOutputCostPerAudioTokenInt] = float64(quota.RmbToFixedPoint(audioOutput)) +``` + +音频单价为可选配置: +- 配置了对应方向音频单价 → 按音频拆分计费; +- 未配置 → 该方向音频 token 保留在普通 input/output 中按文本价格计费,避免漏计。 + +### 6.3 非流式 Usage 解析 + +**文件:** `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` + +函数 `UpdateCtxByUsage` 增加音频字段解析: + +```go +audioInput := gjson.GetBytes(data, "usage.audio_input_tokens").Int() +audioOutput := gjson.GetBytes(data, "usage.audio_output_tokens").Int() + +tokenUsage.AudioInputTokens = audioInput +tokenUsage.AudioOutputTokens = audioOutput +``` + +### 6.4 流式 Usage 解析 + +**文件:** `bfe/bfe_modules/mod_body_process/llm_util.go` + +函数 `SSEEvent.GetQuotaUsage` 返回的 `QuotaUsage` 增加音频字段: + +```go +type QuotaUsage struct { + PromptTokens int64 + CompletionTokens int64 + CacheReadTokens int64 + CacheWriteTokens int64 + AudioInputTokens int64 + AudioOutputTokens int64 + UsedQuota int64 + CurrentTokens int64 + IsGuess bool +} +``` + +函数内解析 `usage.audio_input_tokens`、`usage.audio_output_tokens`。 + +**文件:** `bfe/bfe_modules/mod_body_process/body_process.go` + +非流式响应使用的 `RawEvent.GetQuotaUsage` 同样需要解析 `usage.audio_input_tokens`、`usage.audio_output_tokens`,以保证 `QuotaUsageProcessor` 在两种响应格式下都能正确收集音频用量。 + +**文件:** `bfe/bfe_modules/mod_body_process/content_quota_usage.go` + +`QuotaUsageProcessor.Process` 在非 guess 事件覆盖 `tctx` 时同步写入 `AudioInputTokens`、`AudioOutputTokens`。 + +> 流式场景下音频 usage 通常只在最后一个 SSE 事件中出现,因此最后一个有效事件会覆盖之前的值。 + +### 6.5 RMB 费用计算改造 + +**文件:** `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` + +函数 `calcCostUnits` 使用 `usage *bfe_basic.TokenUsage`: + +```go +func (m *ModuleAITokenAuth) calcCostUnits( + req *bfe_basic.Request, + serverConf bfe_basic.ServerDataConfInterface, + usage *bfe_basic.TokenUsage, +) int64 +``` + +计费逻辑(推荐精细分支版本): + +```go +promptTokens := usage.PromptTokens +completionTokens := usage.CompletionTokens +cacheReadTokens := usage.CacheReadTokens +cacheWriteTokens := usage.CacheWriteTokens +audioInputTokens := usage.AudioInputTokens +audioOutputTokens := usage.AudioOutputTokens + +// 边界保护 +if cacheReadTokens < 0 { + cacheReadTokens = 0 +} +if cacheReadTokens > promptTokens { + cacheReadTokens = promptTokens +} +if cacheWriteTokens < 0 { + cacheWriteTokens = 0 +} +if audioInputTokens < 0 { + audioInputTokens = 0 +} +if audioInputTokens > promptTokens-cacheReadTokens { + audioInputTokens = promptTokens - cacheReadTokens +} +if audioOutputTokens < 0 { + audioOutputTokens = 0 +} +if audioOutputTokens > completionTokens { + audioOutputTokens = completionTokens +} + +cacheReadCost := int64(entry.Prices[cluster_conf.PriceCacheReadInputTokenCostInt]) +cacheWriteCost := int64(entry.Prices[cluster_conf.PriceCacheCreationInputTokenCostInt]) +audioInputCost := int64(entry.Prices[cluster_conf.PriceInputCostPerAudioTokenInt]) +audioOutputCost := int64(entry.Prices[cluster_conf.PriceOutputCostPerAudioTokenInt]) + +// 普通 input/output 初始值 +normalInput := promptTokens +normalOutput := completionTokens + +// cache 拆分 +if cacheReadCost > 0 || cacheWriteCost > 0 { + normalInput = promptTokens - cacheReadTokens + if normalInput < 0 { + normalInput = 0 + } +} + +// 音频 input 拆分:从 normalInput 中再剥离 audio_input +if audioInputCost > 0 { + audioInputTokens = min(audioInputTokens, normalInput) + normalInput = normalInput - audioInputTokens + if normalInput < 0 { + normalInput = 0 + } +} else { + audioInputTokens = 0 // 不配置音频 input 单价时,按文本价格计费 +} + +// 音频 output 拆分 +if audioOutputCost > 0 { + audioOutputTokens = min(audioOutputTokens, completionTokens) + normalOutput = completionTokens - audioOutputTokens + if normalOutput < 0 { + normalOutput = 0 + } +} else { + audioOutputTokens = 0 // 不配置音频 output 单价时,按文本价格计费 +} + +var cost int64 +if cacheReadCost > 0 || cacheWriteCost > 0 || audioInputCost > 0 || audioOutputCost > 0 { + cost = normalInput*inputCost + + cacheReadTokens*cacheReadCost + + cacheWriteTokens*cacheWriteCost + + audioInputTokens*audioInputCost + + normalOutput*outputCost + + audioOutputTokens*audioOutputCost +} else { + cost = promptTokens*inputCost + completionTokens*outputCost +} + +return cost +``` + +调用点无需改动: + +```go +if tokenUsage.UsedCost <= 0 && hasRMBPlan(ctx.Token.QuotaPlans) { + tokenUsage.UsedCost = m.calcCostUnits(req, ctx.serverConf, tokenUsage) +} +``` + +### 6.6 访问日志扩展(建议) + +**文件:** `bfe-access-pb/bfe_access_pb/bfe_access.proto` + +在 AI Observability 区域新增字段: + +```protobuf +optional int64 ai_audio_input_tokens = 783; +optional int64 ai_audio_output_tokens = 784; +``` + +**文件:** `bfe/bfe_modules/mod_access_pb3/request_log.go` + +在 `reqAiInfoGen` 中补充: + +```go +if usage.AudioInputTokens > 0 { + reqLog.AiAudioInputTokens = proto.Int64(usage.AudioInputTokens) +} +if usage.AudioOutputTokens > 0 { + reqLog.AiAudioOutputTokens = proto.Int64(usage.AudioOutputTokens) +} +``` + +修改 proto 后需执行 `bfe-access-pb/build.sh` 重新生成 Go 代码,并同步更新 `bfe-access-pb/docs/protobuf.md`。 + +--- + +## 7. 计费公式速查 + +### 7.1 含音频 Token 的模型 + +前提:`input_cost_per_audio_token` 或 `output_cost_per_audio_token` 已配置。 + +``` +normal_input = max(prompt_tokens - cache_read_tokens - audio_input_tokens, 0) +normal_output = max(completion_tokens - audio_output_tokens, 0) + +cost = normal_input × input_cost_per_token + + cache_read_tokens × cache_read_input_token_cost + + cache_write_tokens × cache_creation_input_token_cost + + audio_input_tokens × input_cost_per_audio_token + + normal_output × output_cost_per_token + + audio_output_tokens × output_cost_per_audio_token +``` + +### 7.2 不含音频 Token 的模型(向后兼容) + +``` +cost = prompt_tokens × input_cost_per_token + + completion_tokens × output_cost_per_token +``` + +--- + +## 8. 边界情况与兼容性 + +| 场景 | 处理建议 | +|---|---| +| 后端未返回音频字段 | 字段值为 0,按不含音频计费 | +| `audio_input_tokens > prompt_tokens` | 截断为 `prompt_tokens - cache_read_tokens`,避免普通 input 为负 | +| `audio_output_tokens > completion_tokens` | 截断为 `completion_tokens`,避免普通 output 为负 | +| `audio_input_tokens` 或 `audio_output_tokens` 为负 | 按 0 处理 | +| 价格表只配置了部分音频单价 | 仅当对应方向音频单价 > 0 时才剥离并单独计价;否则该方向音频 token 保留在普通 input/output 中按文本价格计费 | +| 流式响应中音频 usage 出现在中间事件 | 非 guess 事件覆盖,最终事件为准 | +| `UsedQuota` 维度 | 继续为 `prompt_tokens + completion_tokens`,音频 token 不累加 | +| 与 cache 同时存在 | 按 `prompt - cache_read - audio_input` 计算普通 input;若后端语义有重叠,需 backend 提供_disjoint_ 明细或后续调整策略 | +| 非 chat 模式 | 当前 BFE 仅支持 chat;若后续扩展,需同步修改 `LookupModelPrice` 的 mode 参数 | + +--- + +## 9. 测试计划 + +### 9.1 单元测试 + +在 `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go` 中新增: + +1. `TestCalcCostUnits_Audio`:验证音频 input/output 双向拆分计费公式。 +2. `TestUpdateCtxByUsage_Audio`:验证音频字段解析。 +3. `TestTokenRequestFinishHandler_RMB_Audio_NonStreaming`:非流式端到端扣费。 +4. `TestTokenRequestFinishHandler_RMB_Audio_Streaming`:流式端到端扣费。 +5. `TestCalcCostUnits_AudioFallback`:未配置音频单价时退化。 +6. `TestCalcCostUnits_AudioInputExceedsPrompt`:边界处理。 +7. `TestCalcCostUnits_AudioOutputExceedsCompletion`:边界处理。 +8. `TestCalcCostUnits_CacheAndAudio`:cache 与音频同时存在时的计费正确性。 + +### 9.2 集成测试 + +在 `bfe/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go` 中新增: + +1. `TestTC10_RMBQuotaDeduction_Audio_NonStreaming`: + - 后端返回含 `audio_input_tokens` / `audio_output_tokens` 的非流式响应; + - `ModelTable` 配置音频单价; + - 验证 Redis 扣减金额按音频拆分公式计算。 +2. `TestTC11_RMBQuotaDeduction_Audio_Streaming`: + - 后端返回 SSE 流,最终 chunk 含音频 usage; + - 验证流式场景下 Redis 仍按音频拆分公式扣减。 + +在 `bfe/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go` 中新增: + +3. `TestTC09_AudioTokenFields`: + - 后端返回含 `audio_input_tokens` / `audio_output_tokens` 的 usage; + - `ModelTable` 配置音频单价; + - 验证 `mod_access_pb3` 输出的 b2log 中 `ai_audio_input_tokens`、`ai_audio_output_tokens` 与 audio-aware `ai_cost_value` 正确。 + +### 9.3 配置加载测试 + +在 `bfe/bfe_config/bfe_cluster_conf/cluster_conf/` 中: + +1. 验证 `ModelTableCheck` 正确转换音频单价为定点整数; +2. 验证未配置音频单价时不报错; +3. 验证音频单价为负数时报错。 + +--- + +## 10. 实施步骤建议 + +1. **数据结构与配置层**:扩展 `TokenUsage`;增加音频价格常量及转换逻辑。 +2. **Usage 解析**:非流式与流式模块同时解析音频 usage。 +3. **计费逻辑**:改造 `calcCostUnits`,实现音频 input/output 双向拆分计费及退化逻辑;同步调用点与单元测试。 +4. **日志与可观测性**:扩展 `bfe_access.proto` 与 `request_log.go`。 +5. **测试与回归**:补充单元测试、集成测试,验证现有 RMB / total_token / cache 计费场景不受影响。 + +--- + +## 11. 影响范围 + +| 模块/文件 | 影响 | +|---|---| +| `bfe/bfe_basic/request_ai_basic.go` | `TokenUsage` 新增音频字段 | +| `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` | 新增音频价格常量与转换逻辑 | +| `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` | Usage 解析与费用计算改造 | +| `bfe/bfe_modules/mod_body_process/llm_util.go` | `QuotaUsage` 与 `SSEEvent.GetQuotaUsage` 新增音频字段 | +| `bfe/bfe_modules/mod_body_process/body_process.go` | `RawEvent.GetQuotaUsage` 新增音频字段 | +| `bfe/bfe_modules/mod_body_process/content_quota_usage.go` | 流式/非流式音频 usage 累积 | +| `bfe-access-pb/bfe_access_pb/bfe_access.proto` | 访问日志新增音频字段 | +| `bfe/bfe_modules/mod_access_pb3/request_log.go` | 序列化音频字段 | +| `bfe/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go` | 新增音频计费集成测试 | +| `bfe/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go` | 新增音频访问日志字段集成测试 | +| `bfe/docs/zh_cn/modifications/2026-08-22-audio-token-billing-support/design-changes.md` | 本设计变更文档 | + +--- + +## 12. 兼容性与风险 + +### 12.1 兼容性 + +- 未配置音频单价的模型行为完全不变。 +- Token 配额(`total_token`)扣减逻辑不变。 +- Cache 计费逻辑不变。 +- Redis key 结构、配置格式均不发生改变。 +- `UsedQuota` 继续保持 `prompt_tokens + completion_tokens`。 + +### 12.2 风险与缓解 + +| 风险 | 缓解措施 | +|---|---| +| 后端返回异常音频数据导致普通 input/output 为负 | `calcCostUnits` 中校验并截断 `audio_input_tokens` / `audio_output_tokens` | +| 流式音频 usage 事件提前或延后 | `QuotaUsageProcessor` 在非 guess 事件时覆盖,最终以可靠事件为准 | +| 与 cache 共存时重叠导致普通 input 为负 | 截断保护;若后端语义有重叠,需 backend 提供_disjoint_ 明细 | +| proto 字段变更需重新生成 | 修改后执行 `bfe-access-pb/build.sh` | + +--- + +## 13. 参考资料 + +- `document-ai-gateway/迭代系统设计/v0.5/计费能力扩展/bfe-audio-token-billing-support-analysis.md` +- `document-ai-gateway/迭代系统设计/v0.5/计费能力扩展/bfe-cache-billing-support-analysis.md` +- `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` +- `bfe/bfe_modules/mod_body_process/content_quota_usage.go` +- `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` +- `bfe/bfe_basic/request_ai_basic.go` diff --git a/docs/zh_cn/sys_design/ai_access_log_fields.md b/docs/zh_cn/sys_design/ai_access_log_fields.md index c91a667e6..045846fd7 100644 --- a/docs/zh_cn/sys_design/ai_access_log_fields.md +++ b/docs/zh_cn/sys_design/ai_access_log_fields.md @@ -19,7 +19,7 @@ BFE 作为 AI 网关,需要把请求在认证、路由、转发、计费等各 ## 2. 字段总览 -AI 可观测字段统一占用 `bfe-access-pb` 的 701-900 编号区间,当前已定义 20 个字段: +AI 可观测字段统一占用 `bfe-access-pb` 的 701-900 编号区间,当前已定义 24 个字段: | 编号 | 字段名 | 类型 | 说明 | 采集模块 | |------|--------|------|------|----------| @@ -40,6 +40,10 @@ AI 可观测字段统一占用 `bfe-access-pb` 的 701-900 编号区间,当前 | 715 | `ai_retry_count` | `uint32` | 模型调用层 key-level 重试次数 | `bfe_server/reverseproxy.go` | | 761 | `ai_cost_value` | `int64` | 估算成本(定点整数,精度由币种决定) | `mod_ai_token_auth` | | 762 | `ai_cost_currency` | `string` | 成本币种,如 `RMB` / `USD` | `bfe_server/reverseproxy.go` | +| 781 | `ai_cache_read_tokens` | `int64` | 从 cache 读取的 Token 数(已包含在 `ai_input_tokens` 中) | `mod_ai_token_auth` / `mod_body_process` | +| 782 | `ai_cache_write_tokens` | `int64` | 写入 cache 的 Token 数(独立附加项) | `mod_ai_token_auth` / `mod_body_process` | +| 783 | `ai_audio_input_tokens` | `int64` | 音频输入 Token 数(已包含在 `ai_input_tokens` 中) | `mod_ai_token_auth` / `mod_body_process` | +| 784 | `ai_audio_output_tokens` | `int64` | 音频输出 Token 数(已包含在 `ai_output_tokens` 中) | `mod_ai_token_auth` / `mod_body_process` | | 801 | `ai_route_rule_hits` | `repeated AIRouteRuleHit` | 命中的 AI 路由规则列表 | `mod_ai_route` | | 802 | `ai_cluster_key_names` | `repeated ClusterKeyName` | 请求处理过程中尝试过的 (cluster, key) 列表 | `bfe_server/reverseproxy.go` | | 841 | `ai_auth_hit_quota_plans` | `repeated string` | 正常请求时命中的 Quota Plan ID 列表 | `mod_ai_token_auth` | @@ -50,11 +54,13 @@ AI 可观测字段统一占用 `bfe-access-pb` 的 701-900 编号区间,当前 |----------|------| | 701 - 713 | 已投入使用字段,保持现状,不再调整 | | 714 - 760 | 模型与请求基础信息(model、provider、stream、retry、cache 等) | -| 761 - 800 | Token 与成本计量 | +| 761 - 800 | Token 与成本计量(761-780 为普通 token/cost;781-790 为 cache/audio token 子项) | | 801 - 840 | 路由、转换与插件 | | 841 - 880 | 安全、合规与隐私 | | 881 - 900 | 厂商扩展与预留 | +> 说明:编号 781-790 已用于 `cache_read` / `cache_write` / `audio_input` / `audio_output` 等子项 Token 字段,后续新增子项可继续向 785-790 扩展。 + --- ## 3. 总体架构 @@ -128,10 +134,14 @@ type AiBasicInfo struct { } type TokenUsage struct { - PromptTokens int64 // 706 ai_input_tokens - CompletionTokens int64 // 707 ai_output_tokens - UsedQuota int64 // 708 ai_total_tokens - UsedCost int64 // 761 ai_cost_value + PromptTokens int64 // 706 ai_input_tokens(包含 cache_read_tokens 与 audio_input_tokens) + CompletionTokens int64 // 707 ai_output_tokens(包含 audio_output_tokens) + CacheReadTokens int64 // 781 ai_cache_read_tokens,已包含在 PromptTokens 中 + CacheWriteTokens int64 // 782 ai_cache_write_tokens,独立附加项 + AudioInputTokens int64 // 783 ai_audio_input_tokens,已包含在 PromptTokens 中 + AudioOutputTokens int64 // 784 ai_audio_output_tokens,已包含在 CompletionTokens 中 + UsedQuota int64 // 708 ai_total_tokens + UsedCost int64 // 761 ai_cost_value } type AiAuthInfo struct { @@ -185,8 +195,8 @@ message AIRouteRuleHit { - 在 `ValidateUserTokenByReq()` 中找到 Token 后,立即把 `Token.KeyId` 写入 `AiBasicInfo.ClientKeyId`,确保即使后续拒绝也能在日志中识别 key; - 通过 `SetTokenAuthContext()` 写入 `ApikeyTags` 和初始 `PromptTokens`; -- 在响应阶段解析 `usage` 并估算 token,填充 `TokenUsage`; -- 对 RMB 配额调用 `calcCostUnits()` 计算 `UsedCost`; +- 在响应阶段解析 `usage`(含 `prompt_tokens`、`completion_tokens`、`cache_read_tokens`、`cache_write_tokens`、`audio_input_tokens`、`audio_output_tokens`)并估算 token,填充 `TokenUsage`; +- 对 RMB 配额调用 `calcCostUnits()` 计算 `UsedCost`,支持 input/output、cache read/write、audio input/output 多价格子项拆分; - 在认证成功时记录 `HitQuotaPlans`,拒绝时记录 `RejectReason` 和 `RejectQuotaPlans`。 ### 5.3 `mod_ai_route` @@ -224,7 +234,7 @@ message AIRouteRuleHit { `reqAiInfoGen()` 负责把上述所有字段从 `AiBasicInfo`、`AiRateLimitHitInfo`、`AiRouteResult` 映射到 `RequestLog`: - 字段重命名:`AiApikey`→`AiApikeyId`、`AiMappedModel`→`AiTargetModel`、`AiPromptTokens`→`AiInputTokens`; -- 新增字段:`AiProvider`、`AiRetryCount`、`AiCostValue`、`AiCostCurrency`、`AiRouteRuleHits`、`AiClusterKeyNames`、`AiAuthHitQuotaPlans`。 +- 新增字段:`AiProvider`、`AiRetryCount`、`AiCostValue`、`AiCostCurrency`、`AiCacheReadTokens`、`AiCacheWriteTokens`、`AiAudioInputTokens`、`AiAudioOutputTokens`、`AiRouteRuleHits`、`AiClusterKeyNames`、`AiAuthHitQuotaPlans`。 --- @@ -239,12 +249,14 @@ message AIRouteRuleHit { ## 7. 测试与验证 1. **单元测试**:`bfe_modules/mod_access_pb3/request_log_test.go` 覆盖所有字段的赋值逻辑; -2. **集成测试**:`tests/integration/implementation/scenario-SC05-access-log-ai-fields/` 启动真实 BFE 进程,发送 AI 请求后解码 b2log,校验全部 20 个字段。 +2. **集成测试**:`tests/integration/implementation/scenario-SC05-access-log-ai-fields/` 启动真实 BFE 进程,发送 AI 请求后解码 b2log,校验全部 24 个字段。 --- ## 8. 参考文档 - `bfe-access-pb/docs/protobuf.md` +- `bfe-access-pb/RELEASE_NOTES_v0.3.2.md` - `bfe/docs/zh_cn/modifications/2026-08-19-update-ai-access-log-fields/design-changes.md` +- `bfe/docs/zh_cn/modifications/2026-08-22-audio-token-billing-support/design-changes.md` - `bfe/tests/integration/测试设计文档/scenario-SC05-AI访问日志字段校验/场景说明.md` diff --git a/docs/zh_cn/sys_design/rmb_quota.md b/docs/zh_cn/sys_design/rmb_quota.md index 38da90330..a8776e577 100644 --- a/docs/zh_cn/sys_design/rmb_quota.md +++ b/docs/zh_cn/sys_design/rmb_quota.md @@ -15,13 +15,16 @@ - 把 `prompt_tokens` / `completion_tokens` 换算成人民币成本; - 对 `unit = "RMB"` 的配额计划扣减相应金额。 +v0.5 进一步引入 **cache** 与 **音频 token** 子项计费:后端返回的 `usage` 中可能包含 `cache_read_tokens`、`cache_write_tokens`、`audio_input_tokens`、`audio_output_tokens`,BFE 需要把这些子项从 `prompt_tokens` / `completion_tokens` 中剥离,并按各自价格分别计费。 + ### 1.2 目标 1. 配置层沿用 `AIConf.ModelTable`,价格以 `Prices` map(元/Token)下发,BFE 加载时转换为 1e-8 元/Token 定点整数; 2. `bfe_basic.TokenUsage` 增加 `UsedCost`,用于记录本次请求的 RMB 成本; 3. `mod_ai_token_auth.QuotaPlan` 增加 `Unit`,`Deduct` / `HasBalance` 支持 RMB; 4. 新增共享库 `go-lib/quota`,提供 RMB 定点数转换,供 ai-gateway-api 与 BFE 共同引用; -5. Redis Lua 支持 RMB 扣减脚本,当前暂时使用单 Key 定点数方案。 +5. Redis Lua 支持 RMB 扣减脚本,当前暂时使用单 Key 定点数方案; +6. 支持按 `cache_read_tokens` / `cache_write_tokens` / `audio_input_tokens` / `audio_output_tokens` 子项拆分计费,并与普通 input/output 价格共存。 ## 2. 设计原则 @@ -71,7 +74,7 @@ type ModelPrice struct { Capabilities []string SupportedParameters []string Limits map[string]interface{} - Prices map[string]float64 // 价格对象,如 input_cost_per_token / output_cost_per_token + Prices map[string]float64 // 价格对象,支持 input/output、cache_read/cache_creation、audio_input/audio_output 等 Metadata map[string]interface{} } @@ -98,7 +101,7 @@ type AIConf struct { ### 4.3 校验规则 1. `ModelTable.Currency` 当前仅允许 `"RMB"`。 -2. `ModelPrice.Prices["input_cost_per_token"]`、`Prices["output_cost_per_token"]` 必须 `>= 0`。 +2. `ModelPrice.Prices` 中 `input_cost_per_token`、`output_cost_per_token`、`cache_read_input_token_cost`、`cache_creation_input_token_cost`、`input_cost_per_audio_token`、`output_cost_per_audio_token` 均必须 `>= 0`(未配置时按 `0` 处理)。 3. `Model` 为具体模型名;`Mode` 如 `"chat"`。 4. 同一个 `Mode` 下,`Model` 不能重复。 5. 加载时构建二维索引 `priceIndex[model][mode]`,便于运行时 O(1) 查询。 @@ -122,11 +125,20 @@ func buildModelTableIndex(table *ModelTable) error { // 1. 价格转换:浮点元/Token -> 1e-8 元/Token 定点整数 input := price.Prices["input_cost_per_token"] output := price.Prices["output_cost_per_token"] - if input < 0 || output < 0 { + cacheRead := price.Prices["cache_read_input_token_cost"] + cacheWrite := price.Prices["cache_creation_input_token_cost"] + audioInput := price.Prices["input_cost_per_audio_token"] + audioOutput := price.Prices["output_cost_per_audio_token"] + if input < 0 || output < 0 || cacheRead < 0 || cacheWrite < 0 || + audioInput < 0 || audioOutput < 0 { return fmt.Errorf("negative price for model %s", price.Model) } price.Prices["input_cost_per_token_int"] = float64(quota.RmbToFixedPoint(input)) price.Prices["output_cost_per_token_int"] = float64(quota.RmbToFixedPoint(output)) + price.Prices["cache_read_input_token_cost_int"] = float64(quota.RmbToFixedPoint(cacheRead)) + price.Prices["cache_creation_input_token_cost_int"] = float64(quota.RmbToFixedPoint(cacheWrite)) + price.Prices["input_cost_per_audio_token_int"] = float64(quota.RmbToFixedPoint(audioInput)) + price.Prices["output_cost_per_audio_token_int"] = float64(quota.RmbToFixedPoint(audioOutput)) // 2. 构建 model -> mode 二维索引 if table.priceIndex[price.Model] == nil { @@ -184,10 +196,14 @@ func FromRedisValue(value int64, unit string) float64 ```go type TokenUsage struct { - PromptTokens int64 // 请求侧 Token 数 - CompletionTokens int64 // 响应侧 Token 数 - UsedQuota int64 // 已用 Token 配额(unit=total_token 时使用) - UsedCost int64 // 已用 RMB 成本,1 单位 = 1e-8 元(unit=RMB 时使用) + PromptTokens int64 // 请求侧 Token 数(包含 cache_read_tokens、audio_input_tokens) + CompletionTokens int64 // 响应侧 Token 数(包含 audio_output_tokens) + CacheReadTokens int64 // 从 cache 读取的 Token 数,已包含在 PromptTokens 中 + CacheWriteTokens int64 // 写入 cache 的 Token 数,独立附加项 + AudioInputTokens int64 // 音频输入 Token 数,已包含在 PromptTokens 中 + AudioOutputTokens int64 // 音频输出 Token 数,已包含在 CompletionTokens 中 + UsedQuota int64 // 已用 Token 配额(unit=total_token 时使用) + UsedCost int64 // 已用 RMB 成本,1 单位 = 1e-8 元(unit=RMB 时使用) } ``` @@ -328,7 +344,7 @@ func (m *ModuleAITokenAuth) tokenRequestFinishHandler(req *bfe_basic.Request, re // 统一在请求完成阶段计算 RMB 成本(流式由 mod_body_process 填充 token 用量) if tokenUsage.UsedCost <= 0 && hasRMBPlan(ctx.Token.QuotaPlans) { - tokenUsage.UsedCost = m.calcCostUnits(req, ctx.serverConf, tokenUsage.PromptTokens, tokenUsage.CompletionTokens) + tokenUsage.UsedCost = m.calcCostUnits(req, ctx.serverConf, tokenUsage) } costUnits := tokenUsage.UsedCost @@ -365,9 +381,9 @@ func (m *ModuleAITokenAuth) tokenRequestFinishHandler(req *bfe_basic.Request, re 新增方法(位于 `mod_ai_token_auth`): ```go -func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe_basic.ServerDataConfInterface, promptTokens, completionTokens int64) int64 { +func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe_basic.ServerDataConfInterface, usage *bfe_basic.TokenUsage) int64 { aiMeta := req.GetAiBasicInfo() - if aiMeta == nil { + if aiMeta == nil || usage == nil { return 0 } @@ -392,19 +408,108 @@ func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe return 0 } - // 使用配置加载阶段已转换好的定点整数价格(1 单位 = 1e-8 元) - // 转换由 go-lib/quota.RmbToFixedPoint 统一完成 - inputCost := int64(entry.Prices["input_cost_per_token_int"]) - outputCost := int64(entry.Prices["output_cost_per_token_int"]) + inputCost := int64(entry.Prices[cluster_conf.PriceInputCostPerTokenInt]) + outputCost := int64(entry.Prices[cluster_conf.PriceOutputCostPerTokenInt]) if inputCost < 0 || outputCost < 0 { log.Logger.Warn("invalid model price for cluster %s model %s", clusterName, targetModel) return 0 } - return promptTokens*inputCost + completionTokens*outputCost + promptTokens := usage.PromptTokens + completionTokens := usage.CompletionTokens + cacheReadTokens := usage.CacheReadTokens + cacheWriteTokens := usage.CacheWriteTokens + audioInputTokens := usage.AudioInputTokens + audioOutputTokens := usage.AudioOutputTokens + + // sanitize sub-token usage to avoid negative normal input/output or negative charges + if cacheReadTokens < 0 { + cacheReadTokens = 0 + } + if cacheReadTokens > promptTokens { + cacheReadTokens = promptTokens + } + if cacheWriteTokens < 0 { + cacheWriteTokens = 0 + } + if audioInputTokens < 0 { + audioInputTokens = 0 + } + if audioInputTokens > promptTokens-cacheReadTokens { + audioInputTokens = promptTokens - cacheReadTokens + } + if audioOutputTokens < 0 { + audioOutputTokens = 0 + } + if audioOutputTokens > completionTokens { + audioOutputTokens = completionTokens + } + + cacheReadCost := int64(entry.Prices[cluster_conf.PriceCacheReadInputTokenCostInt]) + cacheWriteCost := int64(entry.Prices[cluster_conf.PriceCacheCreationInputTokenCostInt]) + audioInputCost := int64(entry.Prices[cluster_conf.PriceInputCostPerAudioTokenInt]) + audioOutputCost := int64(entry.Prices[cluster_conf.PriceOutputCostPerAudioTokenInt]) + + normalInput := promptTokens + normalOutput := completionTokens + + if cacheReadCost > 0 || cacheWriteCost > 0 { + normalInput = promptTokens - cacheReadTokens + if normalInput < 0 { + normalInput = 0 + } + } + + if audioInputCost > 0 { + if audioInputTokens > normalInput { + audioInputTokens = normalInput + } + normalInput = normalInput - audioInputTokens + if normalInput < 0 { + normalInput = 0 + } + } else { + audioInputTokens = 0 + } + + if audioOutputCost > 0 { + if audioOutputTokens > completionTokens { + audioOutputTokens = completionTokens + } + normalOutput = completionTokens - audioOutputTokens + if normalOutput < 0 { + normalOutput = 0 + } + } else { + audioOutputTokens = 0 + } + + var cost int64 + if cacheReadCost > 0 || cacheWriteCost > 0 || audioInputCost > 0 || audioOutputCost > 0 { + cost = normalInput*inputCost + + cacheReadTokens*cacheReadCost + + cacheWriteTokens*cacheWriteCost + + audioInputTokens*audioInputCost + + normalOutput*outputCost + + audioOutputTokens*audioOutputCost + } else { + cost = promptTokens*inputCost + completionTokens*outputCost + } + + return cost } ``` +#### cache/audio 计费拆分公式 + +在 `calcCostUnits` 中,RMB 成本按如下优先级拆分: + +1. **Cache read 从 prompt 中剥离**:若配置了 `cache_read_input_token_cost`,则 `normal_input = prompt_tokens - cache_read_tokens`。 +2. **Audio input 从剩余 normal input 中剥离**:若配置了 `input_cost_per_audio_token`,则 `audio_input_tokens` 按 audio 价格计费,其余仍按普通 input 价格计费。 +3. **Audio output 从 completion 中剥离**:若配置了 `output_cost_per_audio_token`,则 `audio_output_tokens` 按 audio 价格计费,其余仍按普通 output 价格计费。 +4. **Cache write 独立计费**:`cache_write_tokens` 不参与 prompt/completion 总量拆分,单独按 `cache_creation_input_token_cost` 计费。 +5. **未配置子项价格时回退**:若某类子项价格未配置(`<= 0`),对应子项仍按普通 input/output 价格计费,保证向后兼容。 + 说明: - `req.Route.ClusterName` 在 `reverseproxy.go` 的 `aiClusterInvoke()` 中已被设置为最终实际使用的 cluster(包括 fallback 场景)。 @@ -558,7 +663,11 @@ return {yuan, frac} }, "Prices": { "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000002 + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": 0.0000005, + "cache_creation_input_token_cost": 0.0000015, + "input_cost_per_audio_token": 0.00002, + "output_cost_per_audio_token": 0.00004 } } ] @@ -567,14 +676,17 @@ return {yuan, frac} } ``` -> 上例中 `input_cost_per_token = 0.000001` 元/Token,换算为固定点整数即 `100`(= 0.000001 * 1e8),表示 **0.1 元 / 百万 Token**。 +> 上例中 `input_cost_per_token = 0.000001` 元/Token,换算为固定点整数即 `100`(= 0.000001 * 1e8),表示 **0.1 元 / 百万 Token**;`cache_read_input_token_cost` 等子项价格同样通过 `quota.RmbToFixedPoint` 转换为定点整数。 ## 10. 测试建议 1. **单元测试** - `QuotaPlan.Deduct`:分别覆盖 `total_token` 和 `RMB` 两种单位,以及余额不足、Key 不存在等边界。 - `lookupModelPrice`:精确匹配、未命中返回 nil。 - - `calcCostUnits`:正常计算、ModelTable 缺失、模型未命中、价格转换精度。 + - `calcCostUnits`:正常计算、ModelTable 缺失、模型未命中、价格转换精度; + - 覆盖 cache read/write 与 audio input/output 子项拆分; + - 覆盖子项用量大于总量时的 clamp 行为; + - 覆盖未配置子项价格时回退到普通 input/output 价格的行为。 2. **Lua 脚本测试** - 单 Key 定点数方案:验证扣减、余额归零、负数不溢出。 @@ -585,6 +697,7 @@ return {yuan, frac} - 测试 `ModelMapping` 场景:请求模型是 `gpt-4`,实际后端模型是 `deepseek-chat`,验证按 `deepseek-chat` 的价格计费。 - 测试 fallback 场景:请求最终 fallback 到另一个 cluster,验证按最终 cluster + target_model 计费。 - 测试流式(SSE)场景:请求体带 `stream: true`,后端返回 SSE 并在最后一个 chunk 中携带 `usage`,验证 RMB 配额仍能正确扣减。 + - 测试 cache/audio 子项计费场景:后端返回 `usage.cache_read_tokens`、`usage.cache_write_tokens`、`usage.audio_input_tokens`、`usage.audio_output_tokens`,验证成本按各子项价格拆分计算。 ## 11. 兼容性与注意事项 @@ -595,6 +708,6 @@ return {yuan, frac} - 本次请求不对该 RMB 配额进行扣减(相当于按 `0` 成本处理); - 具体是否拒绝请求,需产品进一步确认。 4. **与多 Key 改造的关系**:`AIConf.Keys` 与 `ModelTable` 相互独立,可并行下发、独立解析。 -5. **流式响应计费**:RMB 成本在请求结束阶段计算,依赖 `mod_body_process`(或其他响应处理模块)在流式传输过程中填充 `PromptTokens` / `CompletionTokens`。生产环境若启用流式计费,需确保 `mod_body_process` 已加载。 +5. **流式响应计费**:RMB 成本在请求结束阶段计算,依赖 `mod_body_process`(或其他响应处理模块)在流式传输过程中填充 `PromptTokens` / `CompletionTokens` / `CacheReadTokens` / `CacheWriteTokens` / `AudioInputTokens` / `AudioOutputTokens`。生产环境若启用流式计费,需确保 `mod_body_process` 已加载。 6. **模块顺序建议**:`mod_ai_token_auth` 的 `HandleReadResponse` 不再负责 RMB 成本计算,因此对模块加载顺序的敏感度降低;但仍建议保持 `mod_ai_token_auth` 在 `mod_body_process` 之前注册,以便非流式场景下 token 用量解析逻辑保持一致。 7. **旧字段清理**:`AIConf.Key` 已移除,统一使用 `AIConf.Keys`。 diff --git a/go.mod b/go.mod index e6ed060f4..8d2b16705 100644 --- a/go.mod +++ b/go.mod @@ -70,7 +70,7 @@ require ( require ( github.com/HdrHistogram/hdrhistogram-go v1.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/bfenetworks/bfe-access-pb v0.3.1 + github.com/bfenetworks/bfe-access-pb v0.3.2 github.com/bfenetworks/bfe-mock-waf v0.1.0 github.com/bfenetworks/bwi v0.1.2 github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index 8f2a505ae..5e5a123c3 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bfenetworks/bfe-access-pb v0.3.1 h1:+eRfg0wz6NHLUXSWdmZs2FUP3nckyhK0dxz2XAzzDo4= -github.com/bfenetworks/bfe-access-pb v0.3.1/go.mod h1:3qunybhMV5hEeT/2qDwbchxo4t/9LPKEvwwZkmekRho= +github.com/bfenetworks/bfe-access-pb v0.3.2 h1:tMU5xQRk2tZ98ZS+uUB8idRhN0cEVviAlzpeHnilYEg= +github.com/bfenetworks/bfe-access-pb v0.3.2/go.mod h1:3qunybhMV5hEeT/2qDwbchxo4t/9LPKEvwwZkmekRho= github.com/bfenetworks/bfe-mock-waf v0.1.0 h1:dTd540S3nv6qNlG6lLC0F8qx3gyo5WB4wnci3+d4J78= github.com/bfenetworks/bfe-mock-waf v0.1.0/go.mod h1:MWZHbihiRQXpoUCvY1l18s2bfOBWx4N4pghxBt+xUv0= github.com/bfenetworks/bwi v0.1.2 h1:3AcCzUjyzKm+FeLgTIVg58u+SUdEVZdJbQM58Ezwjcg= diff --git a/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go b/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go index 2becd6de8..ad02a6b9f 100644 --- a/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go +++ b/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go @@ -51,6 +51,8 @@ var modelMappingBody = []byte(`{"model":"gpt-4"}`) var streamBody = []byte(`{"model":"deepseek-chat","stream":true}`) var cacheBody = []byte(`{"model":"claude-opus-4-6"}`) var cacheStreamBody = []byte(`{"model":"claude-opus-4-6","stream":true}`) +var audioBody = []byte(`{"model":"gpt-audio-1.5"}`) +var audioStreamBody = []byte(`{"model":"gpt-audio-1.5","stream":true}`) var usageResponse = `{"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}}` @@ -66,6 +68,13 @@ var cacheStreamUsageResponse = "data: {\"choices\":[{\"delta\":{\"role\":\"assis "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n" + "data: {\"usage\":{\"prompt_tokens\":8000,\"completion_tokens\":1500,\"total_tokens\":9500,\"cache_read_tokens\":5000,\"cache_write_tokens\":1000}}\n\n" +var audioUsageResponse = `{"usage":{"prompt_tokens":4000,"completion_tokens":500,"total_tokens":4500,"audio_input_tokens":1000,"audio_output_tokens":200}}` + +// SSE format with audio usage in the final chunk. +var audioStreamUsageResponse = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n" + + "data: {\"usage\":{\"prompt_tokens\":4000,\"completion_tokens\":500,\"total_tokens\":4500,\"audio_input_tokens\":1000,\"audio_output_tokens\":200}}\n\n" + // testEnv holds all resources for a single SC03 integration test. type testEnv struct { t *testing.T @@ -239,12 +248,29 @@ func defaultRMBAIConf() *cluster_conf.AIConf { "context_window": 128000, }, Prices: map[string]float64{ - "input_cost_per_token": 0.00000452, - "output_cost_per_token": 0.00002262, - "cache_read_input_token_cost": 0.00000045, + "input_cost_per_token": 0.00000452, + "output_cost_per_token": 0.00002262, + "cache_read_input_token_cost": 0.00000045, "cache_creation_input_token_cost": 0.00000565, }, }, + { + Provider: "mock-provider", + Model: "gpt-audio-1.5", + BaseModel: "gpt-audio-1.5", + Mode: "chat", + Capabilities: []string{"chat", "audio_input"}, + SupportedParameters: []string{"temperature", "max_tokens"}, + Limits: map[string]interface{}{ + "context_window": 128000, + }, + Prices: map[string]float64{ + "input_cost_per_token": 0.00000178, + "output_cost_per_token": 0.00000715, + "input_cost_per_audio_token": 0.00002288, + "output_cost_per_audio_token": 0.00004576, + }, + }, }, }, } @@ -604,3 +630,81 @@ func TestTC09_RMBQuotaDeduction_Cache_Streaming(t *testing.T) { t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) } } + +// TestTC10 verifies RMB quota deduction with audio pricing for non-streaming responses. +func TestTC10_RMBQuotaDeduction_Audio_NonStreaming(t *testing.T) { + aiConfs := map[string]*cluster_conf.AIConf{ + clusterRMB: defaultRMBAIConf(), + } + e := newTestEnv(t, aiConfs, []common.QuotaPlan{rmbQuotaPlan(10000000000)}) + defer e.Close() + + e.redis.SetQuota(redisKeyRMB, 10000000000) + + // Configure backend to return response with audio usage. + e.backends[clusterRMB].Body = audioUsageResponse + + resp, body, err := e.sendRequest(apiHost, audioBody) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if e.backends[clusterRMB].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterRMB, e.backends[clusterRMB].Hits()) + } + + // Wait for async redis deduction. + time.Sleep(500 * time.Millisecond) + remaining := e.redis.GetQuota(redisKeyRMB) + // normal_input = 4000 - 1000 = 3000 + // normal_output = 500 - 200 = 300 + // cost = 3000*178 + 1000*2288 + 300*715 + 200*4576 = 3951700 + want := int64(10000000000 - 3951700) + if remaining != want { + e.logBFEException() + e.logBFEAccess() + t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) + } +} + +// TestTC11 verifies RMB quota deduction with audio pricing for streaming (SSE) responses. +func TestTC11_RMBQuotaDeduction_Audio_Streaming(t *testing.T) { + aiConfs := map[string]*cluster_conf.AIConf{ + clusterRMB: defaultRMBAIConf(), + } + e := newTestEnv(t, aiConfs, []common.QuotaPlan{rmbQuotaPlan(10000000000)}) + defer e.Close() + + e.redis.SetQuota(redisKeyRMB, 10000000000) + + // Configure backend to return SSE stream with audio usage in the final chunk. + e.backends[clusterRMB].ResponseHeaders = map[string]string{"Content-Type": "text/event-stream"} + e.backends[clusterRMB].Body = audioStreamUsageResponse + + resp, body, err := e.sendRequest(apiHost, audioStreamBody) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if e.backends[clusterRMB].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterRMB, e.backends[clusterRMB].Hits()) + } + + // Wait for async redis deduction after response finishes. + time.Sleep(500 * time.Millisecond) + remaining := e.redis.GetQuota(redisKeyRMB) + want := int64(10000000000 - 3951700) + if remaining != want { + e.logBFEException() + e.logBFEAccess() + t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) + } +} diff --git a/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go b/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go index 3010f13e6..64f0c5e33 100644 --- a/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go +++ b/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go @@ -26,9 +26,9 @@ import ( "testing" "time" + bfe_access_pb "github.com/bfenetworks/bfe-access-pb/bfe_access_pb" "github.com/bfenetworks/bfe/bfe_basic" "github.com/bfenetworks/bfe/bfe_config/bfe_cluster_conf/cluster_conf" - bfe_access_pb "github.com/bfenetworks/bfe-access-pb/bfe_access_pb" "github.com/bfenetworks/bfe/tests/integration/common" ) @@ -55,6 +55,7 @@ var streamBody = []byte(`{"model":"deepseek-chat","stream":true}`) var usageResponse = `{"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}}` var cacheUsageResponse = `{"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150,"cache_read_tokens":30,"cache_write_tokens":20}}` +var audioUsageResponse = `{"usage":{"prompt_tokens":4000,"completion_tokens":500,"total_tokens":4500,"audio_input_tokens":1000,"audio_output_tokens":200}}` // SSE format: final chunk contains usage. The trailing blank line is required. var streamUsageResponse = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n" + @@ -275,10 +276,32 @@ func fallbackRMBAIConf() *cluster_conf.AIConf { func cacheRMBAIConf() *cluster_conf.AIConf { conf := defaultRMBAIConf() conf.ModelTable.Models[0].Prices = map[string]float64{ - "input_cost_per_token": 0.000001, - "output_cost_per_token": 0.000002, - "cache_read_input_token_cost": 0.0000005, - "cache_creation_input_token_cost": 0.0000015, + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": 0.0000005, + "cache_creation_input_token_cost": 0.0000015, + } + return conf +} + +func audioRMBAIConf() *cluster_conf.AIConf { + conf := defaultRMBAIConf() + conf.ModelTable.Models[0] = cluster_conf.ModelPrice{ + Provider: "mock-provider", + Model: "gpt-audio-1.5", + BaseModel: "gpt-audio-1.5", + Mode: "chat", + Capabilities: []string{"chat", "audio_input"}, + SupportedParameters: []string{"temperature", "max_tokens"}, + Limits: map[string]interface{}{ + "context_window": 128000, + }, + Prices: map[string]float64{ + "input_cost_per_token": 0.00000178, + "output_cost_per_token": 0.00000715, + "input_cost_per_audio_token": 0.00002288, + "output_cost_per_audio_token": 0.00004576, + }, } return conf } @@ -322,7 +345,6 @@ func tokenQuotaPlan(quota int64) common.QuotaPlan { } } - // TestTC01 verifies all major AI access log fields for a successful RMB request. func TestTC01_SuccessfulRequestAIFields(t *testing.T) { aiConfs := map[string]*cluster_conf.AIConf{ @@ -590,7 +612,6 @@ func TestTC06_StreamingFields(t *testing.T) { assertInt64Field(t, reqLog.AiOutputTokens, "ai_output_tokens", 50) } - type expectedRouteRuleHit struct { Owner string OwnerType string @@ -759,9 +780,6 @@ OpenDebug = false return os.WriteFile(filepath.Join(modDir, "ai_rate_limit.data"), data, 0644) } - - - // TestTC07 verifies ai_rate_limit_hits when RPM limit is triggered. func TestTC07_RateLimitHits(t *testing.T) { aiConfs := map[string]*cluster_conf.AIConf{ @@ -864,3 +882,44 @@ func TestTC08_CacheTokenFields(t *testing.T) { assertInt64Field(t, reqLog.AiCostValue, "ai_cost_value", 21500) assertStringField(t, reqLog.AiCostCurrency, "ai_cost_currency", "RMB") } + +// TestTC09 verifies ai_audio_input_tokens, ai_audio_output_tokens and audio-aware cost. +func TestTC09_AudioTokenFields(t *testing.T) { + aiConfs := map[string]*cluster_conf.AIConf{ + clusterRMB: audioRMBAIConf(), + } + e := newTestEnv(t, aiConfs, []common.QuotaPlan{rmbQuotaPlan(10000000000)}, false) + defer e.Close() + + e.redis.SetQuota(redisKeyRMB, 10000000000) + e.backends[clusterRMB].Body = audioUsageResponse + + resp, body, err := e.sendRequest(apiHost, []byte(`{"model":"gpt-audio-1.5"}`)) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if e.backends[clusterRMB].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterRMB, e.backends[clusterRMB].Hits()) + } + + // Wait for access log to be flushed before stopping BFE. + time.Sleep(500 * time.Millisecond) + + e.stopBFE() + e.stopBFE = nil + + reqLog := e.mustFindSingleLog(e.accessLogs()) + assertInt64Field(t, reqLog.AiInputTokens, "ai_input_tokens", 4000) + assertInt64Field(t, reqLog.AiOutputTokens, "ai_output_tokens", 500) + assertInt64Field(t, reqLog.AiTotalTokens, "ai_total_tokens", 4500) + assertInt64Field(t, reqLog.AiAudioInputTokens, "ai_audio_input_tokens", 1000) + assertInt64Field(t, reqLog.AiAudioOutputTokens, "ai_audio_output_tokens", 200) + // cost = (4000-1000)*178 + 1000*2288 + (500-200)*715 + 200*4576 = 3951700 + assertInt64Field(t, reqLog.AiCostValue, "ai_cost_value", 3951700) + assertStringField(t, reqLog.AiCostCurrency, "ai_cost_currency", "RMB") +} diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-09-Audio\346\250\241\345\236\213\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-09-Audio\346\250\241\345\236\213\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" new file mode 100644 index 000000000..8c8ebae70 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-09-Audio\346\250\241\345\236\213\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" @@ -0,0 +1,85 @@ +# TC-09 Audio 模型非流式计费 + +## 用例编号与名称 + +TC-09 Audio 模型非流式计费 + +## 所属场景 + +SC03 RMB 配额扣减 + +## 版本声明 + +- `bfe`:当前源码版本 +- `bfe-access-pb`:`v0.3.2` + +## 测试目的 + +验证当模型价格表中配置了音频相关单价时,BFE 对非流式响应按音频 input/output 拆分公式计费: + +``` +normal_input = prompt_tokens - audio_input_tokens +normal_output = completion_tokens - audio_output_tokens +cost = normal_input * input_cost + audio_input_tokens * audio_input_cost + + normal_output * output_cost + audio_output_tokens * audio_output_cost +``` + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动,并预置 `quota:plan_rmb = 10000000000`。 +3. mock 后端 `cluster_rmb` 已启动,返回 200 与如下 body: + ```json + { + "usage": { + "prompt_tokens": 4000, + "completion_tokens": 500, + "total_tokens": 4500, + "audio_input_tokens": 1000, + "audio_output_tokens": 200 + } + } + ``` +4. 临时 BFE 配置已加载,`cluster_rmb` 的 `ModelTable` 包含模型 `gpt-audio-1.5`,价格: + - `input_cost_per_token`: `0.00000178` → 定点整数 `178` + - `output_cost_per_token`: `0.00000715` → 定点整数 `715` + - `input_cost_per_audio_token`: `0.00002288` → 定点整数 `2288` + - `output_cost_per_audio_token`: `0.00004576` → 定点整数 `4576` +5. `ak_user_a` 绑定 RMB 配额计划 `plan_rmb`。 + +## 配置构造 + +- `cluster_rmb.AIConf.ModelTable.Models` 增加模型 `gpt-audio-1.5`: + - `input_cost_per_token`: `0.00000178` + - `output_cost_per_token`: `0.00000715` + - `input_cost_per_audio_token`: `0.00002288` + - `output_cost_per_audio_token`: `0.00004576` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `rmb.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"gpt-audio-1.5"}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_rmb` 收到 1 次命中(200)。 +- Redis 中 `quota:plan_rmb` 的余额按音频拆分公式扣减: + - normal_input = 4000 - 1000 = 3000 + - normal_output = 500 - 200 = 300 + - 扣减金额 = 3000 * 178 + 1000 * 2288 + 300 * 715 + 200 * 4576 = 3951700 + - 剩余 = `10000000000 - 3951700 = 99996048300` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-10-Audio\346\250\241\345\236\213\346\265\201\345\274\217\350\256\241\350\264\271.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-10-Audio\346\250\241\345\236\213\346\265\201\345\274\217\350\256\241\350\264\271.md" new file mode 100644 index 000000000..a2a587bc5 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-10-Audio\346\250\241\345\236\213\346\265\201\345\274\217\350\256\241\350\264\271.md" @@ -0,0 +1,75 @@ +# TC-10 Audio 模型流式计费 + +## 用例编号与名称 + +TC-10 Audio 模型流式计费 + +## 所属场景 + +SC03 RMB 配额扣减 + +## 版本声明 + +- `bfe`:当前源码版本 +- `bfe-access-pb`:`v0.3.2` + +## 测试目的 + +验证当模型价格表中配置了音频相关单价时,BFE 对 SSE 流式响应按音频 input/output 拆分公式计费。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动,并预置 `quota:plan_rmb = 10000000000`。 +3. mock 后端 `cluster_rmb` 已启动,返回 SSE 流,最终 chunk 包含 audio usage: + ```text + data: {"choices":[{"delta":{"role":"assistant"}}]} + + data: {"choices":[{"delta":{"content":"hello"}}]} + + data: {"usage":{"prompt_tokens":4000,"completion_tokens":500,"total_tokens":4500,"audio_input_tokens":1000,"audio_output_tokens":200}} + + ``` +4. 临时 BFE 配置已加载,`cluster_rmb` 的 `ModelTable` 包含模型 `gpt-audio-1.5`,价格: + - `input_cost_per_token`: `0.00000178` → 定点整数 `178` + - `output_cost_per_token`: `0.00000715` → 定点整数 `715` + - `input_cost_per_audio_token`: `0.00002288` → 定点整数 `2288` + - `output_cost_per_audio_token`: `0.00004576` → 定点整数 `4576` +5. `ak_user_a` 绑定 RMB 配额计划 `plan_rmb`。 + +## 配置构造 + +- `cluster_rmb.AIConf.ModelTable.Models` 增加模型 `gpt-audio-1.5`: + - `input_cost_per_token`: `0.00000178` + - `output_cost_per_token`: `0.00000715` + - `input_cost_per_audio_token`: `0.00002288` + - `output_cost_per_audio_token`: `0.00004576` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `rmb.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"gpt-audio-1.5","stream":true}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_rmb` 收到 1 次命中(200)。 +- Redis 中 `quota:plan_rmb` 的余额按音频拆分公式扣减: + - normal_input = 4000 - 1000 = 3000 + - normal_output = 500 - 200 = 300 + - 扣减金额 = 3000 * 178 + 1000 * 2288 + 300 * 715 + 200 * 4576 = 3951700 + - 剩余 = `10000000000 - 3951700 = 99996048300` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" index 1915322f5..ec018e004 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" @@ -12,7 +12,8 @@ BFE v0.4 在 `mod_ai_token_auth` 中引入 RMB(人民币)配额支持。与 - Token 配额与 RMB 配额共存时分别扣减; - cluster 未配置 `ModelTable` 或模型未命中时按 0 成本处理; - fallback 到另一个 cluster 后按最终 cluster + target_model 计费; -- 含 cache 的模型按 cache 拆分公式计费(非流式与流式)。 +- 含 cache 的模型按 cache 拆分公式计费(非流式与流式); +- 含音频的模型按音频 input/output 拆分公式计费(非流式与流式)。 ## 2. 运行模式 @@ -100,6 +101,23 @@ BFE v0.4 在 `mod_ai_token_auth` 中引入 RMB(人民币)配额支持。与 "cache_read_input_token_cost": 0.00000045, "cache_creation_input_token_cost": 0.00000565 } + }, + { + "Provider": "mock-provider", + "Model": "gpt-audio-1.5", + "BaseModel": "gpt-audio-1.5", + "Mode": "chat", + "Capabilities": ["chat", "audio_input"], + "SupportedParameters": ["temperature", "max_tokens"], + "Limits": { + "context_window": 128000 + }, + "Prices": { + "input_cost_per_token": 0.00000178, + "output_cost_per_token": 0.00000715, + "input_cost_per_audio_token": 0.00002288, + "output_cost_per_audio_token": 0.00004576 + } } ] } @@ -116,6 +134,11 @@ BFE v0.4 在 `mod_ai_token_auth` 中引入 RMB(人民币)配额支持。与 > - `output_cost_per_token = 0.00002262` → 定点整数 `2262` > - `cache_read_input_token_cost = 0.00000045` → 定点整数 `45` > - `cache_creation_input_token_cost = 0.00000565` → 定点整数 `565` +> - `gpt-audio-1.5` 配置音频单价: +> - `input_cost_per_token = 0.00000178` → 定点整数 `178` +> - `output_cost_per_token = 0.00000715` → 定点整数 `715` +> - `input_cost_per_audio_token = 0.00002288` → 定点整数 `2288` +> - `output_cost_per_audio_token = 0.00004576` → 定点整数 `4576` ### 4.3 `cluster_fallback_rmb` 的 `AIConf` @@ -206,6 +229,8 @@ BFE v0.4 在 `mod_ai_token_auth` 中引入 RMB(人民币)配额支持。与 | TC-06 | Fallback 后按最终 cluster 计费 | `cluster_rmb` 返回 502 触发 fallback 到 `cluster_fallback_rmb`,按后者价格扣减 | | TC-07 | Cache 模型非流式计费 | 后端返回含 `cache_read_tokens` / `cache_write_tokens` 的响应,按 cache 拆分公式扣减 | | TC-08 | Cache 模型流式计费 | SSE 最终 chunk 含 cache usage,按 cache 拆分公式扣减 | +| TC-09 | Audio 模型非流式计费 | 后端返回含 `audio_input_tokens` / `audio_output_tokens` 的响应,按音频拆分公式扣减 | +| TC-10 | Audio 模型流式计费 | SSE 最终 chunk 含 audio usage,按音频拆分公式扣减 | ## 7. 公共基础设施改造 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-09-Audio Token\345\255\227\346\256\265.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-09-Audio Token\345\255\227\346\256\265.md" new file mode 100644 index 000000000..faabad857 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-09-Audio Token\345\255\227\346\256\265.md" @@ -0,0 +1,91 @@ +# TC-09 Audio Token 字段 + +## 用例编号与名称 + +TC-09 Audio Token 字段 + +## 所属场景 + +SC05 AI 访问日志字段校验 + +## 版本声明 + +- `bfe`:当前源码版本 +- `bfe-access-pb`:`v0.3.2` + +## 测试目的 + +验证后端返回包含 `audio_input_tokens` / `audio_output_tokens` 的 usage 时,`mod_access_pb3` 输出的 b2log 中 `ai_audio_input_tokens`、`ai_audio_output_tokens` 被正确填充,且 `ai_cost_value` 按 audio-aware 公式计算。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动,并预置 `quota:plan_rmb = 10000000000`(100 元)。 +3. mock 后端 `cluster_rmb` 已启动,返回 200 与如下 body: + ```json + { + "usage": { + "prompt_tokens": 4000, + "completion_tokens": 500, + "total_tokens": 4500, + "audio_input_tokens": 1000, + "audio_output_tokens": 200 + } + } + ``` +4. 临时 BFE 配置已加载,`cluster_rmb` 配置 `Provider = "mock-provider"`、`ModelTable.Currency = "RMB"`,且价格表包含音频价格: + - `input_cost_per_token = 0.00000178` + - `output_cost_per_token = 0.00000715` + - `input_cost_per_audio_token = 0.00002288` + - `output_cost_per_audio_token = 0.00004576` +5. `ak_user_a` 绑定 RMB 配额计划 `plan_rmb`。 +6. 启用 `mod_access_pb3`,b2log 输出到临时 `log/` 目录。 + +## 配置构造 + +- `cluster_rmb.AIConf`: + - `Provider`: `mock-provider` + - `ModelTable.Currency`: `RMB` + - `ModelTable.Models[0].Model`: `gpt-audio-1.5` + - `Prices.input_cost_per_token`: `0.00000178` + - `Prices.output_cost_per_token`: `0.00000715` + - `Prices.input_cost_per_audio_token`: `0.00002288` + - `Prices.output_cost_per_audio_token`: `0.00004576` + - `Keys`: 单 Key `key-primary` +- `plan_rmb`: + - `Unit`: `RMB` + - `Quota`: `10000000000` + - `RedisKey`: `quota:plan_rmb` +- `ai_route.data` 中 `apikey_ak_user_a` 命中 `user_a-rmb`,target 为 `cluster_rmb`。 + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `rmb.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"gpt-audio-1.5"}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_rmb` 收到 1 次命中。 +- b2log 中存在 1 条 `RequestLog`,且字段满足: + - `ai_input_tokens` = `4000` + - `ai_output_tokens` = `500` + - `ai_total_tokens` = `4500` + - `ai_audio_input_tokens` = `1000` + - `ai_audio_output_tokens` = `200` + - `ai_cost_value` = `(4000 - 1000) * 178 + 1000 * 2288 + (500 - 200) * 715 + 200 * 4576 = 3951700` + - `ai_cost_currency` = `"RMB"` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" index 4c5d6caa1..843f7004e 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" @@ -12,7 +12,8 @@ - `ai_retry_count` 在多 Key 重试时正确累加; - `ai_route_rule_hits`、`ai_cluster_key_names`、`ai_auth_hit_quota_plans` 与请求行为一致; - `ai_auth_reject_reason`、`ai_auth_reject_quota_plans` 在配额拒绝场景正确输出; -- `ai_cache_read_tokens`、`ai_cache_write_tokens` 在 cache 计费场景正确输出。 +- `ai_cache_read_tokens`、`ai_cache_write_tokens` 在 cache 计费场景正确输出; +- `ai_audio_input_tokens`、`ai_audio_output_tokens` 在音频计费场景正确输出。 ## 2. 运行模式 @@ -180,6 +181,7 @@ | TC-06 | 流式响应字段 | SSE 请求下校验 `ai_stream`、`ai_ttft_us`、`ai_tpot_us` | | TC-07 | 限流命中字段 | 触发 RPM/TPM 限流,校验 `ai_rate_limit_hits` | | TC-08 | Cache Token 字段 | 后端返回含 `cache_read_tokens` / `cache_write_tokens` 的 usage,校验访问日志中 `ai_cache_read_tokens`、`ai_cache_write_tokens` 与 cache-aware 成本 | +| TC-09 | Audio Token 字段 | 后端返回含 `audio_input_tokens` / `audio_output_tokens` 的 usage,校验访问日志中 `ai_audio_input_tokens`、`ai_audio_output_tokens` 与 audio-aware 成本 | ## 7. 公共基础设施改造 From afd22d0c261b9a8daf18ead69eea4c487c27a5fd Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Sat, 22 Aug 2026 13:32:51 +0800 Subject: [PATCH 05/18] feat: support image generation per-image billing - Add Mode to AiBasicInfo and DetectModeFromPath for path-based mode inference - Add ImageCount to TokenUsage and QuotaUsage - Support output_cost_per_image in ModelTable price conversion - Update usage parsing to read image_count / data.# / request.n fallback - Branch calcCostUnits by mode with calcImageGenerationCost and calcChatCost - Log ai_mode and ai_image_count in mod_access_pb3 - Add integration tests for image generation RMB billing and access log fields - Update bfe-access-pb dependency to v0.3.3 - Update sys_design and test design docs Signed-off-by: zhangmiao --- bfe_basic/request_ai_basic.go | 43 ++ .../cluster_conf/cluster_conf_load.go | 6 +- bfe_modules/mod_access_pb3/request_log.go | 8 + .../mod_ai_token_auth/mod_ai_token_auth.go | 68 +- bfe_modules/mod_body_process/body_process.go | 7 +- .../mod_body_process/content_quota_usage.go | 5 +- bfe_modules/mod_body_process/llm_util.go | 8 +- bfe_server/http_conn.go | 4 +- .../design-changes.md | 582 ++++++++++++++++++ docs/zh_cn/sys_design/ai_access_log_fields.md | 25 +- docs/zh_cn/sys_design/rmb_quota.md | 135 +++- go.mod | 2 +- go.sum | 4 +- .../sc03_rmb_quota_test.go | 63 +- .../sc05_access_log_ai_fields_test.go | 68 +- ...01\345\274\217\350\256\241\350\264\271.md" | 2 +- ...01\345\274\217\350\256\241\350\264\271.md" | 2 +- ...11\346\254\241\350\256\241\350\264\271.md" | 74 +++ ...72\346\231\257\350\257\264\346\230\216.md" | 22 +- ...09-Audio Token\345\255\227\346\256\265.md" | 2 +- ...mageGeneration\345\255\227\346\256\265.md" | 88 +++ ...72\346\231\257\350\257\264\346\230\216.md" | 18 +- ...73\344\275\223\350\257\264\346\230\216.md" | 18 +- 23 files changed, 1197 insertions(+), 57 deletions(-) create mode 100644 docs/zh_cn/modifications/2026-08-22-image-generation-billing-support/design-changes.md create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-12-ImageGeneration\346\250\241\345\236\213\346\214\211\346\254\241\350\256\241\350\264\271.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-10-ImageGeneration\345\255\227\346\256\265.md" diff --git a/bfe_basic/request_ai_basic.go b/bfe_basic/request_ai_basic.go index a10b3c045..a99b4f529 100644 --- a/bfe_basic/request_ai_basic.go +++ b/bfe_basic/request_ai_basic.go @@ -31,6 +31,22 @@ const ( COMPLETION_TOKENS_UNKNOWN = -1 ) +// AI request modes, inferred from request path. +const ( + ModeChat = "chat" + ModeCompletion = "completion" + ModeImageGeneration = "image_generation" + ModeImageEdit = "image_edit" + ModeEmbedding = "embedding" + ModeAudioSpeech = "audio_speech" + ModeAudioTranscription = "audio_transcription" + ModeRerank = "rerank" + ModeVideoGeneration = "video_generation" + ModeOcr = "ocr" + ModeSearch = "search" + ModeRealtime = "realtime" +) + type TokenUsage struct { PromptTokens int64 // number of tokens in the prompt (includes cache_read_tokens, audio_input_tokens) CompletionTokens int64 // number of tokens in the completion (includes audio_output_tokens) @@ -38,6 +54,7 @@ type TokenUsage struct { CacheWriteTokens int64 // usage.cache_write_tokens, independent add-on item AudioInputTokens int64 // usage.audio_input_tokens, already included in PromptTokens AudioOutputTokens int64 // usage.audio_output_tokens, already included in CompletionTokens + ImageCount int64 // number of generated images for image generation models UsedQuota int64 // used quota for this request (unit=total_token) UsedCost int64 // used RMB cost for this request, 1 unit = 1e-8 yuan (unit=RMB) } @@ -67,6 +84,7 @@ type AiBasicInfo struct { ClientKeyId string ClientModel string TargetModel string + Mode string // request mode, e.g. chat, image_generation Provider string // upstream model provider, e.g. openai, deepseek RetryCount uint32 // model invocation retry count (key-level retry) CostCurrency string // cost currency, e.g. RMB, USD @@ -111,6 +129,31 @@ func GetApiKey(req *Request) string { return authHeader } +// DetectModeFromPath infers the AI request mode from the request path. +// It defaults to ModeChat for unknown paths to keep backward compatibility. +func DetectModeFromPath(path string) string { + switch { + case strings.HasPrefix(path, "/v1/images/generations"): + return ModeImageGeneration + case strings.HasPrefix(path, "/v1/images/edits"): + return ModeImageEdit + case strings.HasPrefix(path, "/v1/chat/completions"): + return ModeChat + case strings.HasPrefix(path, "/v1/completions"): + return ModeCompletion + case strings.HasPrefix(path, "/v1/embeddings"): + return ModeEmbedding + case strings.HasPrefix(path, "/v1/audio/speech"): + return ModeAudioSpeech + case strings.HasPrefix(path, "/v1/audio/transcriptions"): + return ModeAudioTranscription + case strings.HasPrefix(path, "/v1/rerank"): + return ModeRerank + default: + return ModeChat + } +} + // Set user context by key and val. func (r *Request) InitAiBasicInfo() *AiBasicInfo { ret := &AiBasicInfo{} diff --git a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go index 8b8c6668d..8eaf7da4d 100644 --- a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go +++ b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go @@ -187,6 +187,7 @@ const ( PriceCacheCreationInputTokenCost = "cache_creation_input_token_cost" PriceInputCostPerAudioToken = "input_cost_per_audio_token" PriceOutputCostPerAudioToken = "output_cost_per_audio_token" + PriceOutputCostPerImage = "output_cost_per_image" PriceInputCostPerTokenInt = "input_cost_per_token_int" PriceOutputCostPerTokenInt = "output_cost_per_token_int" @@ -194,6 +195,7 @@ const ( PriceCacheCreationInputTokenCostInt = "cache_creation_input_token_cost_int" PriceInputCostPerAudioTokenInt = "input_cost_per_audio_token_int" PriceOutputCostPerAudioTokenInt = "output_cost_per_audio_token_int" + PriceOutputCostPerImageInt = "output_cost_per_image_int" ) func (conf *BackendHTTPS) GetProtocol() string { @@ -816,8 +818,9 @@ func ModelTableCheck(table *ModelTable) error { cacheWrite := price.Prices[PriceCacheCreationInputTokenCost] audioInput := price.Prices[PriceInputCostPerAudioToken] audioOutput := price.Prices[PriceOutputCostPerAudioToken] + outputCostPerImage := price.Prices[PriceOutputCostPerImage] if input < 0 || output < 0 || cacheRead < 0 || cacheWrite < 0 || - audioInput < 0 || audioOutput < 0 { + audioInput < 0 || audioOutput < 0 || outputCostPerImage < 0 { return fmt.Errorf("negative price for model %s", price.Model) } @@ -827,6 +830,7 @@ func ModelTableCheck(table *ModelTable) error { price.Prices[PriceCacheCreationInputTokenCostInt] = float64(quota.RmbToFixedPoint(cacheWrite)) price.Prices[PriceInputCostPerAudioTokenInt] = float64(quota.RmbToFixedPoint(audioInput)) price.Prices[PriceOutputCostPerAudioTokenInt] = float64(quota.RmbToFixedPoint(audioOutput)) + price.Prices[PriceOutputCostPerImageInt] = float64(quota.RmbToFixedPoint(outputCostPerImage)) if table.priceIndex[price.Model] == nil { table.priceIndex[price.Model] = make(map[string]*ModelPrice) diff --git a/bfe_modules/mod_access_pb3/request_log.go b/bfe_modules/mod_access_pb3/request_log.go index 1437de077..70656b4ae 100644 --- a/bfe_modules/mod_access_pb3/request_log.go +++ b/bfe_modules/mod_access_pb3/request_log.go @@ -395,6 +395,11 @@ func reqAiInfoGen(reqLog *bfe_access_pb3.RequestLog, req *bfe_basic.Request, res reqLog.AiTargetModel = proto.String(aiInfo.TargetModel) } + // Mode + if aiInfo.Mode != "" { + reqLog.AiMode = proto.String(aiInfo.Mode) + } + // Provider if aiInfo.Provider != "" { reqLog.AiProvider = proto.String(aiInfo.Provider) @@ -421,6 +426,9 @@ func reqAiInfoGen(reqLog *bfe_access_pb3.RequestLog, req *bfe_basic.Request, res if usage.AudioOutputTokens > 0 { reqLog.AiAudioOutputTokens = proto.Int64(usage.AudioOutputTokens) } + if usage.ImageCount > 0 { + reqLog.AiImageCount = proto.Int64(usage.ImageCount) + } if usage.UsedCost > 0 { reqLog.AiCostValue = proto.Int64(usage.UsedCost) } diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go index 01eaba7e3..ffd112bd8 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go @@ -112,7 +112,7 @@ func (m *ModuleAITokenAuth) matchTokenRule(req *bfe_basic.Request) bool { } func UpdateCtxByUsage(ctx *TokenAuthContext, data []byte) { - var used, prompt, completion, cacheRead, cacheWrite, audioInput, audioOutput int64 + var used, prompt, completion, cacheRead, cacheWrite, audioInput, audioOutput, imageCount int64 used = gjson.GetBytes(data, "usage.total_tokens").Int() prompt = gjson.GetBytes(data, "usage.prompt_tokens").Int() @@ -121,6 +121,10 @@ func UpdateCtxByUsage(ctx *TokenAuthContext, data []byte) { cacheWrite = gjson.GetBytes(data, "usage.cache_write_tokens").Int() audioInput = gjson.GetBytes(data, "usage.audio_input_tokens").Int() audioOutput = gjson.GetBytes(data, "usage.audio_output_tokens").Int() + imageCount = gjson.GetBytes(data, "usage.image_count").Int() + if imageCount == 0 { + imageCount = gjson.GetBytes(data, "data.#").Int() + } tokenUsage := ctx.aiBasicInfo.GetTokenUsage() if used > 0 { @@ -131,14 +135,20 @@ func UpdateCtxByUsage(ctx *TokenAuthContext, data []byte) { tokenUsage.CacheWriteTokens = cacheWrite tokenUsage.AudioInputTokens = audioInput tokenUsage.AudioOutputTokens = audioOutput - } else if prompt > 0 || completion > 0 { - tokenUsage.UsedQuota = prompt + completion + tokenUsage.ImageCount = imageCount + } else if prompt > 0 || completion > 0 || imageCount > 0 { + if imageCount > 0 { + tokenUsage.UsedQuota = imageCount + } else { + tokenUsage.UsedQuota = prompt + completion + } tokenUsage.PromptTokens = prompt tokenUsage.CompletionTokens = completion tokenUsage.CacheReadTokens = cacheRead tokenUsage.CacheWriteTokens = cacheWrite tokenUsage.AudioInputTokens = audioInput tokenUsage.AudioOutputTokens = audioOutput + tokenUsage.ImageCount = imageCount } } @@ -376,6 +386,21 @@ func GetTokenAuthContext(req *bfe_basic.Request) *TokenAuthContext { return tokenCtx } +// GetImageCountFromReq reads the request body "n" field for image generation requests. +// It returns at least 1 to avoid under-billing when the field is missing or invalid. +func GetImageCountFromReq(req *bfe_basic.Request) int64 { + bodyAccessor, _ := req.HttpRequest.GetBodyAccessor() + if bodyAccessor == nil { + return 1 + } + body, _ := bodyAccessor.GetBytes() + n := gjson.GetBytes(body, "n").Int() + if n <= 0 { + return 1 + } + return n +} + // SetTokenAuthContext sets the token authentication context in the request func SetTokenAuthContext(req *bfe_basic.Request, tok *Token, promptToken int64, tags []bfe_basic.ApikeyTag) { aiBasicInfo := req.GetAiBasicInfo() @@ -384,6 +409,9 @@ func SetTokenAuthContext(req *bfe_basic.Request, tok *Token, promptToken int64, tusage := aiBasicInfo.GetTokenUsage() tusage.PromptTokens = promptToken tusage.CompletionTokens = bfe_basic.COMPLETION_TOKENS_UNKNOWN // -1 - unknown + if aiBasicInfo.Mode == bfe_basic.ModeImageGeneration { + tusage.ImageCount = GetImageCountFromReq(req) + } aiBasicInfo.ApikeyTags = tags } @@ -430,6 +458,10 @@ func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe clusterName := req.Route.ClusterName targetModel := aiMeta.TargetModel + mode := aiMeta.Mode + if mode == "" { + mode = bfe_basic.ModeChat + } if clusterName == "" || targetModel == "" { return 0 } @@ -443,16 +475,40 @@ func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe return 0 } - entry := cluster_conf.LookupModelPrice(cluster.AIConf.ModelTable, targetModel, "chat") + entry := cluster_conf.LookupModelPrice(cluster.AIConf.ModelTable, targetModel, mode) if entry == nil { - log.Logger.Warn("model price not found for cluster %s model %s", clusterName, targetModel) + log.Logger.Warn("model price not found for cluster %s model %s mode %s", clusterName, targetModel, mode) return 0 } + switch mode { + case bfe_basic.ModeImageGeneration: + return calcImageGenerationCost(entry, usage) + default: + return calcChatCost(entry, usage) + } +} + +func calcImageGenerationCost(entry *cluster_conf.ModelPrice, usage *bfe_basic.TokenUsage) int64 { + imageCount := usage.ImageCount + if imageCount < 0 { + imageCount = 0 + } + + costPerImage := int64(entry.Prices[cluster_conf.PriceOutputCostPerImageInt]) + if costPerImage < 0 { + log.Logger.Warn("invalid model price for image generation model %s", entry.Model) + return 0 + } + + return imageCount * costPerImage +} + +func calcChatCost(entry *cluster_conf.ModelPrice, usage *bfe_basic.TokenUsage) int64 { inputCost := int64(entry.Prices[cluster_conf.PriceInputCostPerTokenInt]) outputCost := int64(entry.Prices[cluster_conf.PriceOutputCostPerTokenInt]) if inputCost < 0 || outputCost < 0 { - log.Logger.Warn("invalid model price for cluster %s model %s", clusterName, targetModel) + log.Logger.Warn("invalid model price for model %s", entry.Model) return 0 } diff --git a/bfe_modules/mod_body_process/body_process.go b/bfe_modules/mod_body_process/body_process.go index 5b1cc260e..18c9c3dfe 100644 --- a/bfe_modules/mod_body_process/body_process.go +++ b/bfe_modules/mod_body_process/body_process.go @@ -430,7 +430,11 @@ func (e *RawEvent) GetQuotaUsage() QuotaUsage { cacheWrite := gjson.GetBytes(*e, "usage.cache_write_tokens").Int() audioInput := gjson.GetBytes(*e, "usage.audio_input_tokens").Int() audioOutput := gjson.GetBytes(*e, "usage.audio_output_tokens").Int() - if used > 0 { + imageCount := gjson.GetBytes(*e, "usage.image_count").Int() + if imageCount == 0 { + imageCount = gjson.GetBytes(*e, "data.#").Int() + } + if used > 0 || imageCount > 0 { isguess = false } else { curtoken = EstimateContentToken(string(*e)) @@ -443,6 +447,7 @@ func (e *RawEvent) GetQuotaUsage() QuotaUsage { CacheWriteTokens: cacheWrite, AudioInputTokens: audioInput, AudioOutputTokens: audioOutput, + ImageCount: imageCount, UsedQuota: used, CurrentTokens: curtoken, IsGuess: isguess, diff --git a/bfe_modules/mod_body_process/content_quota_usage.go b/bfe_modules/mod_body_process/content_quota_usage.go index 934bb552d..3811dccc1 100644 --- a/bfe_modules/mod_body_process/content_quota_usage.go +++ b/bfe_modules/mod_body_process/content_quota_usage.go @@ -50,7 +50,10 @@ func (caf *QuotaUsageProcessor) Process(events []Event) ([]Event, error) { if !rquota.IsGuess { // not got usage yet, try to get from event data //mod_ai_token_auth.UpdateCtxByUsage(tctx, data) - if rquota.UsedQuota > 0 { + if rquota.ImageCount > 0 { + tctx.ImageCount = rquota.ImageCount + tctx.UsedQuota = rquota.ImageCount + } else if rquota.UsedQuota > 0 { tctx.CompletionTokens = rquota.CompletionTokens tctx.PromptTokens = rquota.PromptTokens tctx.CacheReadTokens = rquota.CacheReadTokens diff --git a/bfe_modules/mod_body_process/llm_util.go b/bfe_modules/mod_body_process/llm_util.go index c69320924..86737fb17 100644 --- a/bfe_modules/mod_body_process/llm_util.go +++ b/bfe_modules/mod_body_process/llm_util.go @@ -36,6 +36,7 @@ type QuotaUsage struct { CacheWriteTokens int64 // usage.cache_write_tokens, independent add-on item AudioInputTokens int64 // usage.audio_input_tokens, already included in PromptTokens AudioOutputTokens int64 // usage.audio_output_tokens, already included in CompletionTokens + ImageCount int64 // number of generated images for image generation models UsedQuota int64 // used quota for this request //estimate for current response @@ -128,10 +129,14 @@ func (e *SSEEvent) GetQuotaUsage() QuotaUsage { cacheWrite := gjson.GetBytes(data, "usage.cache_write_tokens").Int() audioInput := gjson.GetBytes(data, "usage.audio_input_tokens").Int() audioOutput := gjson.GetBytes(data, "usage.audio_output_tokens").Int() + imageCount := gjson.GetBytes(data, "usage.image_count").Int() + if imageCount == 0 { + imageCount = gjson.GetBytes(data, "data.#").Int() + } curtoken := int64(0) isguess := true - if used > 0 { + if used > 0 || imageCount > 0 { isguess = false } else { curtoken = EstimateContentToken(string(data)) @@ -144,6 +149,7 @@ func (e *SSEEvent) GetQuotaUsage() QuotaUsage { CacheWriteTokens: cacheWrite, AudioInputTokens: audioInput, AudioOutputTokens: audioOutput, + ImageCount: imageCount, UsedQuota: used, CurrentTokens: curtoken, IsGuess: isguess, diff --git a/bfe_server/http_conn.go b/bfe_server/http_conn.go index 820119c93..a21fe19b6 100644 --- a/bfe_server/http_conn.go +++ b/bfe_server/http_conn.go @@ -553,7 +553,9 @@ func (c *conn) serveRequest(w bfe_http.ResponseWriter, request *bfe_basic.Reques aiMeta.ClientModel = model aiMeta.TargetModel = model } - log.Logger.Debug("conn.serveRequest(), ClientApiKey:%s, ClientModel:%s", apikey, model) + + aiMeta.Mode = bfe_basic.DetectModeFromPath(request.HttpRequest.URL.Path) + log.Logger.Debug("conn.serveRequest(), ClientApiKey:%s, ClientModel:%s, Mode:%s", apikey, model, aiMeta.Mode) } // serve the request diff --git a/docs/zh_cn/modifications/2026-08-22-image-generation-billing-support/design-changes.md b/docs/zh_cn/modifications/2026-08-22-image-generation-billing-support/design-changes.md new file mode 100644 index 000000000..fbf12aa7b --- /dev/null +++ b/docs/zh_cn/modifications/2026-08-22-image-generation-billing-support/design-changes.md @@ -0,0 +1,582 @@ +# BFE 支持图像生成按次计费能力扩展 + +## 1. 背景 + +AI 网关场景下,图像生成模型(如 `flux-2-pro`)不按 Token 计费,而是按实际生成的图像张数计费。`model-list.yaml` 中对应的价格字段为 `output_cost_per_image`,响应 usage 中对应字段为 `image_count`。 + +当前 BFE 的 RMB 计费逻辑仅支持 `mode = "chat"` 的 Token 计费,`calcCostUnits` 中硬编码 `"chat"`,且未识别 `output_cost_per_image` 与 `image_count`。本变更在不破坏现有 RMB / total_token / cache / audio 计费逻辑的前提下,扩展 BFE 对 `image_generation` 模式按次计费的端到端支持,并补充请求 mode 识别与访问日志字段。 + +--- + +## 2. 需求示例 + +参考 `model-list对账示例.md` 示例 3 的模型与 usage: + +| 价格字段 | USD / 张 | +|---|---| +| `output_cost_per_image` | `0.03` | + +| Usage 字段 | 值 | +|---|---| +| `image_count` | 2 | + +计费公式: + +``` +cost = image_count × output_cost_per_image + = 2 × 0.03 + = 0.06 USD +``` + +--- + +## 3. 当前现状 + +| 层级 | 当前能力 | 不足 | +|---|---|---| +| 数据结构 | `bfe_basic.AiBasicInfo` 无 `Mode`;`TokenUsage` 仅含文本/cache/audio 字段 | 缺少 `Mode`、`ImageCount` | +| 价格配置 | `ModelPrice.Prices` 已识别文本、cache、音频单价 | 缺少 `output_cost_per_image` | +| Usage 解析 | 非流式 `UpdateCtxByUsage`、流式 `SSEEvent.GetQuotaUsage` / `QuotaUsageProcessor.Process` 只解析 Token 相关字段 | 不识别 `image_count`,也不识别图像生成响应 `data` 数组 | +| 费用计算 | `calcCostUnits` 硬编码 `mode = "chat"`,仅按 Token 计费 | 无法按 `image_generation` 模式匹配定价条目,无法按次计费 | +| 访问日志 | `RequestLog` 已有文本/cache/audio 明细 | 缺少 `ai_mode`、`ai_image_count` | +| 请求转发 | 转发链路路径无关,已能透传 `/v1/images/generations` | 转发正常,但计费侧无法识别 | + +--- + +## 4. 变更目标 + +1. 在 `AiBasicInfo` 中增加 `Mode`,在 `TokenUsage` / `QuotaUsage` 中增加 `ImageCount`。 +2. 在模型价格表中支持 `output_cost_per_image`。 +3. 根据请求路径推断请求 mode(如 `/v1/images/generations` → `image_generation`)。 +4. 改造 usage 解析、费用计算、访问日志三个环节,识别并正确按图像张数计费。 +5. 未配置 `output_cost_per_image` 时按 0 成本 fail-open 处理,保持向后兼容。 +6. `image_generation` 模式下 `UsedQuota` 使用 `image_count`,使 `total_token` 配额也能按次扣减。 +7. 补充单元测试与集成测试。 + +--- + +## 5. 变更总览 + +| 模块 | 主要改动 | +|---|---| +| `bfe_server` | `http_conn.go` 中根据请求路径设置 `AiBasicInfo.Mode` | +| `bfe_basic` | `AiBasicInfo` 增加 `Mode`;`TokenUsage` 增加 `ImageCount`;新增 `DetectModeFromPath` | +| `bfe_config/bfe_cluster_conf/cluster_conf` | 增加 `output_cost_per_image` 常量,`ModelTableCheck` 识别并转换为定点整数 | +| `mod_ai_token_auth` | `UpdateCtxByUsage` 解析 `image_count` / `data.#`;`calcCostUnits` 按 mode 分支计费 | +| `mod_body_process` | `QuotaUsage` / `SSEEvent.GetQuotaUsage` / `RawEvent.GetQuotaUsage` / `QuotaUsageProcessor.Process` 解析并累积 `image_count` | +| `bfe-access-pb` / `mod_access_pb3` | 访问日志新增 `ai_mode`、`ai_image_count`(可选但建议) | +| 测试 | 补充图像生成计费、非流式、流式、无 usage 时按 data 数组/request.n fallback 等测试 | + +--- + +## 6. 详细设计 + +### 6.1 扩展 `AiBasicInfo` 与 `TokenUsage` + +**文件:** `bfe/bfe_basic/request_ai_basic.go` + +```go +const ( + ModeChat = "chat" + ModeCompletion = "completion" + ModeImageGeneration = "image_generation" + ModeImageEdit = "image_edit" + ModeEmbedding = "embedding" + ModeAudioSpeech = "audio_speech" + ModeAudioTranscription = "audio_transcription" + ModeRerank = "rerank" + ModeVideoGeneration = "video_generation" + ModeOcr = "ocr" + ModeSearch = "search" + ModeRealtime = "realtime" +) + +type AiBasicInfo struct { + ClientApiKey string + ClientKeyId string + ClientModel string + TargetModel string + Mode string // 新增:请求模式 + Provider string + RetryCount uint32 + CostCurrency string + tokenUsage TokenUsage + // ... +} + +type TokenUsage struct { + PromptTokens int64 + CompletionTokens int64 + CacheReadTokens int64 + CacheWriteTokens int64 + ImageCount int64 // 新增:本次请求生成的图像张数 + UsedQuota int64 // unit=total_token + UsedCost int64 // unit=RMB,1 unit = 1e-8 yuan +} +``` + +说明: +- `Mode` 在请求进入 BFE 时根据 `HttpRequest.URL.Path` 设置。 +- `ImageCount` 用于 `image_generation` 模式,`total_token` 配额按 `ImageCount` 扣减。 + +### 6.2 请求 mode 识别 + +**文件:** `bfe/bfe_server/http_conn.go` + +在 `serveRequest()` 初始化 `AiBasicInfo` 后: + +```go +if c.server.Config.Server.EnableAiGateway { + aiMeta := request.InitAiBasicInfo() + aiMeta.SetAllowEstimateToken(c.server.Config.Server.EstimateToken) + apikey := bfe_basic.GetApiKey(request) + if len(apikey) > 0 { + aiMeta.ClientApiKey = apikey + } + + model, err := condition.ReqBodyJsonFetch(request, "model", nil) + if err == nil || len(model) > 0 { + aiMeta.ClientModel = model + aiMeta.TargetModel = model + } + + aiMeta.Mode = bfe_basic.DetectModeFromPath(request.HttpRequest.URL.Path) +} +``` + +**文件:** `bfe/bfe_basic/request_ai_basic.go` + +```go +func DetectModeFromPath(path string) string { + switch { + case strings.HasPrefix(path, "/v1/images/generations"): + return ModeImageGeneration + case strings.HasPrefix(path, "/v1/images/edits"): + return ModeImageEdit + case strings.HasPrefix(path, "/v1/chat/completions"): + return ModeChat + case strings.HasPrefix(path, "/v1/completions"): + return ModeCompletion + case strings.HasPrefix(path, "/v1/embeddings"): + return ModeEmbedding + case strings.HasPrefix(path, "/v1/audio/speech"): + return ModeAudioSpeech + case strings.HasPrefix(path, "/v1/audio/transcriptions"): + return ModeAudioTranscription + case strings.HasPrefix(path, "/v1/rerank"): + return ModeRerank + default: + return ModeChat + } +} +``` + +说明: +- 默认 fallback 为 `chat`,保持向后兼容。 +- 后续可扩展为从 cluster / provider 配置中读取 endpoint → mode 映射。 + +### 6.3 扩展价格配置 + +**文件:** `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` + +新增常量: + +```go +const ( + PriceOutputCostPerImage = "output_cost_per_image" + PriceOutputCostPerImageInt = "output_cost_per_image_int" +) +``` + +在 `ModelTableCheck` 中增加图像单价的读取与定点转换: + +```go +outputCostPerImage := price.Prices[PriceOutputCostPerImage] +if outputCostPerImage < 0 { + return fmt.Errorf("negative price for model %s", price.Model) +} +price.Prices[PriceOutputCostPerImageInt] = float64(quota.RmbToFixedPoint(outputCostPerImage)) +``` + +说明: +- `output_cost_per_image` 为可选配置。 +- 未配置时按 fail-open 处理,`cost = 0`。 + +### 6.4 非流式 Usage 解析 + +**文件:** `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` + +函数 `UpdateCtxByUsage` 增加 `image_count` 解析与兜底: + +```go +imageCount := gjson.GetBytes(data, "usage.image_count").Int() +if imageCount == 0 { + imageCount = gjson.GetBytes(data, "data.#").Int() +} + +tokenUsage.ImageCount = imageCount + +if used > 0 { + tokenUsage.UsedQuota = used +} else if prompt > 0 || completion > 0 || imageCount > 0 { + if imageCount > 0 { + tokenUsage.UsedQuota = imageCount + } else { + tokenUsage.UsedQuota = prompt + completion + } +} +``` + +说明: +- 优先读取 `usage.image_count`。 +- 未返回时统计响应 `data` 数组长度(OpenAI 风格图像生成响应)。 +- 若仍未获得,可在请求阶段预读请求体 `n` 字段写入 `ImageCount`。 + +### 6.5 请求阶段预读 `n` 字段 + +`n` 是 OpenAI 风格图像生成接口请求体中的参数,表示**请求生成多少张图片**。示例: + +```json +{ + "model": "dall-e-3", + "prompt": "a white cat", + "n": 2, + "size": "1024x1024" +} +``` + +- `n = 2` 表示希望生成 2 张图。 +- 若未传 `n`,OpenAI 默认值为 `1`。 + +部分后端(包括 OpenAI 原生图像生成接口)的响应不返回 `usage.image_count`,只返回 `data` 数组。BFE 计费需要知道实际生成了多少张图,因此按以下优先级获取 `image_count`: + +1. 响应 `usage.image_count`; +2. 响应 `data` 数组长度; +3. 请求体 `n` 字段; +4. 仍未获得则默认 `1`。 + +预读 `n` 是为了在响应缺少用量信息时,仍能用请求参数兜底计费,避免漏计。 + +**文件:** `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` + +新增辅助函数: + +```go +func GetImageCountFromReq(req *bfe_basic.Request) int64 { + bodyAccessor, _ := req.HttpRequest.GetBodyAccessor() + if bodyAccessor == nil { + return 1 + } + body, _ := bodyAccessor.GetBytes() + n := gjson.GetBytes(body, "n").Int() + if n <= 0 { + return 1 + } + return n +} +``` + +在 `SetTokenAuthContext` 中: + +```go +if aiBasicInfo != nil && aiBasicInfo.Mode == bfe_basic.ModeImageGeneration { + tusage.ImageCount = GetImageCountFromReq(req) +} +``` + +说明: +- 请求阶段按 `n` 预读,响应返回后若 `usage.image_count` 或 `data.#` 更大则覆盖。 +- OpenAI 默认 `n=1`。 + +### 6.6 流式 Usage 解析 + +**文件:** `bfe/bfe_modules/mod_body_process/llm_util.go` + +函数 `SSEEvent.GetQuotaUsage` 返回的 `QuotaUsage` 增加 `ImageCount`: + +```go +type QuotaUsage struct { + PromptTokens int64 + CompletionTokens int64 + CacheReadTokens int64 + CacheWriteTokens int64 + ImageCount int64 // 新增 + UsedQuota int64 + CurrentTokens int64 + IsGuess bool +} +``` + +函数内解析 `usage.image_count`,未返回时解析 `data.#`。 + +**文件:** `bfe/bfe_modules/mod_body_process/body_process.go` + +非流式响应使用的 `RawEvent.GetQuotaUsage` 同样需要解析 `usage.image_count` / `data.#`。 + +**文件:** `bfe/bfe_modules/mod_body_process/content_quota_usage.go` + +`QuotaUsageProcessor.Process` 在非 guess 事件覆盖 `tctx` 时同步写入 `ImageCount`,并优先用 `ImageCount` 作为 `UsedQuota`。 + +### 6.7 RMB 费用计算改造 + +**文件:** `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` + +函数 `calcCostUnits` 按 mode 分支: + +```go +func (m *ModuleAITokenAuth) calcCostUnits( + req *bfe_basic.Request, + serverConf bfe_basic.ServerDataConfInterface, + usage *bfe_basic.TokenUsage, +) int64 { + aiMeta := req.GetAiBasicInfo() + if aiMeta == nil || usage == nil { + return 0 + } + + clusterName := req.Route.ClusterName + targetModel := aiMeta.TargetModel + mode := aiMeta.Mode + if mode == "" { + mode = bfe_basic.ModeChat + } + if clusterName == "" || targetModel == "" { + return 0 + } + + cluster, err := serverConf.ClusterTableLookup(clusterName) + if err != nil || cluster == nil || cluster.AIConf == nil || cluster.AIConf.ModelTable == nil { + log.Logger.Warn("model table not found for cluster %s", clusterName) + return 0 + } + + entry := cluster_conf.LookupModelPrice(cluster.AIConf.ModelTable, targetModel, mode) + if entry == nil { + log.Logger.Warn("model price not found for cluster %s model %s mode %s", clusterName, targetModel, mode) + return 0 + } + + switch mode { + case bfe_basic.ModeImageGeneration: + return calcImageGenerationCost(entry, usage) + default: + return calcChatCost(entry, usage) + } +} + +func calcImageGenerationCost(entry *cluster_conf.ModelPrice, usage *bfe_basic.TokenUsage) int64 { + imageCount := usage.ImageCount + if imageCount < 0 { + imageCount = 0 + } + + costPerImage := int64(entry.Prices[cluster_conf.PriceOutputCostPerImageInt]) + if costPerImage < 0 { + log.Logger.Warn("invalid model price for image generation model %s", entry.Model) + return 0 + } + + return imageCount * costPerImage +} +``` + +调用点无需改动: + +```go +if tokenUsage.UsedCost <= 0 && hasRMBPlan(ctx.Token.QuotaPlans) { + tokenUsage.UsedCost = m.calcCostUnits(req, ctx.serverConf, tokenUsage) +} +``` + +### 6.8 访问日志扩展(建议) + +**文件:** `bfe-access-pb/bfe_access_pb/bfe_access.proto` + +在 AI Observability 区域新增字段: + +```protobuf +// 716 - 720: AI request mode +optional string ai_mode = 716; + +// 785 - 790: image metering +optional int64 ai_image_count = 785; +``` + +**文件:** `bfe/bfe_modules/mod_access_pb3/request_log.go` + +在 `reqAiInfoGen` 中补充: + +```go +if aiInfo.Mode != "" { + reqLog.AiMode = proto.String(aiInfo.Mode) +} +if usage.ImageCount > 0 { + reqLog.AiImageCount = proto.Int64(usage.ImageCount) +} +``` + +修改 proto 后需执行 `bfe-access-pb/build.sh` 重新生成 Go 代码,并同步更新 `bfe-access-pb/docs/protobuf.md`。 + +--- + +## 7. 计费公式速查 + +### 7.1 图像生成模型 + +前提:`mode = "image_generation"` 且 `output_cost_per_image` 已配置。 + +``` +image_count = usage.image_count + ?? len(response.data) + ?? request.n + ?? 1 + +cost = image_count × output_cost_per_image +``` + +### 7.2 与示例 3 的对齐验证 + +``` +image_count = 2 +cost = 2 × 0.03 = 0.06 USD +``` + +--- + +## 8. 边界情况与兼容性 + +| 场景 | 处理建议 | +|---|---| +| 后端未返回 `usage.image_count` | fallback 到响应 `data` 数组长度 | +| 响应也没有 `data` 数组 | fallback 到请求体 `n` 字段;未传时默认 `1` | +| `image_count` 为负 | 按 `0` 处理 | +| 价格表未配置 `output_cost_per_image` | fail-open:记录 Warn 日志,`cost = 0`,不扣减 RMB 配额 | +| 请求路径无法识别 mode | 默认 `chat`,保持现有文本模型计费行为 | +| `total_token` 配额计划 | `image_generation` 模式下 `UsedQuota = image_count` | +| RMB 配额计划 | `image_generation` 模式下按 `image_count × output_cost_per_image` 扣减 | +| 模型映射后 target_model 变化 | 按映射后的 `target_model` + `mode` 匹配定价条目 | +| fallback 到另一个 cluster | 按最终命中的 `cluster` + `target_model` + `mode` 计费 | +| 图像生成请求转发 | BFE 已可正常透传 `/v1/images/generations`,无需改造转发链路 | + +--- + +## 9. 测试计划 + +### 9.1 单元测试 + +在 `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go` 中新增: + +1. `TestDetectModeFromPath`:验证各路径对应的 mode 推断正确。 +2. `TestCalcCostUnits_ImageGeneration`:验证 `image_count × output_cost_per_image` 公式正确。 +3. `TestUpdateCtxByUsage_ImageGeneration`:验证 `usage.image_count` 与 `data.#` 解析正确。 +4. `TestTokenRequestFinishHandler_RMB_ImageGeneration`:非流式图像生成请求端到端扣费。 +5. `TestCalcCostUnits_ImageGenerationMissingPrice`:未配置 `output_cost_per_image` 时按 0 处理。 +6. `TestCalcCostUnits_ImageGenerationFallbackN`:响应无 usage 时按请求 `n` 字段计费。 + +### 9.2 集成测试 + +在 `bfe/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go` 中新增: + +1. `TestTC12_RMBQuotaDeduction_ImageGeneration`: + - 后端返回含 `usage.image_count = 2` 的图像生成响应; + - `ModelTable` 配置 `output_cost_per_image = 0.03`; + - 验证 Redis 扣减金额与示例 3 期望一致(0.06)。 + +在 `bfe/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go` 中新增: + +2. `TestTC10_ImageGenerationFields`: + - 后端返回图像生成响应; + - `ModelTable` 配置 `output_cost_per_image`; + - 验证 `mod_access_pb3` 输出的 b2log 中 `ai_mode`、`ai_image_count`、`ai_cost_value` 正确。 + +### 9.3 配置加载测试 + +在 `bfe/bfe_config/bfe_cluster_conf/cluster_conf/` 中: + +1. 验证 `ModelTableCheck` 正确转换 `output_cost_per_image` 为定点整数; +2. 验证未配置 `output_cost_per_image` 时不报错; +3. 验证 `output_cost_per_image` 为负数时报错; +4. 验证 `LookupModelPrice` 按 `(model, mode)` 精确命中 `image_generation` 条目。 + +--- + +## 10. 实施步骤建议 + +1. **mode 识别与数据结构** + - `bfe_basic.AiBasicInfo` 增加 `Mode`。 + - 新增 `DetectModeFromPath` 并在 `bfe_server/http_conn.go` 中调用。 + - `bfe_basic.TokenUsage` 增加 `ImageCount`。 + +2. **配置层** + - `cluster_conf_load.go` 增加 `output_cost_per_image` 常量及转换逻辑。 + +3. **Usage 解析** + - `UpdateCtxByUsage` 解析非流式 `image_count` / `data.#`。 + - `SSEEvent.GetQuotaUsage` / `RawEvent.GetQuotaUsage` / `QuotaUsageProcessor.Process` 解析流式 `image_count`。 + - `SetTokenAuthContext` 中预读请求 `n` 字段作为兜底。 + +4. **计费逻辑** + - 改造 `calcCostUnits`,按 `mode` 分支计费。 + - 同步修改调用点与单元测试。 + +5. **日志与可观测性(可选但建议)** + - 扩展 `bfe_access.proto`(新增 `ai_mode`、`ai_image_count`)。 + - 修改 `reqAiInfoGen` 记录 `ai_mode`、`ai_image_count`。 + - 更新 `bfe-access-pb/docs/protobuf.md`。 + +6. **测试与回归** + - 补充单元测试、集成测试。 + - 回归验证现有 RMB / total_token / cache / audio 计费场景不受影响。 + +--- + +## 11. 影响范围 + +| 模块/文件 | 影响 | +|---|---| +| `bfe/bfe_server/http_conn.go` | 设置 `AiBasicInfo.Mode` | +| `bfe/bfe_basic/request_ai_basic.go` | `AiBasicInfo` 新增 `Mode`;`TokenUsage` 新增 `ImageCount`;新增 `DetectModeFromPath` | +| `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` | 新增 `output_cost_per_image` 价格常量与转换逻辑 | +| `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` | Usage 解析与费用计算按 mode 分支改造 | +| `bfe/bfe_modules/mod_body_process/llm_util.go` | `QuotaUsage` 与 `SSEEvent.GetQuotaUsage` 新增 `ImageCount` | +| `bfe/bfe_modules/mod_body_process/body_process.go` | `RawEvent.GetQuotaUsage` 新增 `ImageCount` | +| `bfe/bfe_modules/mod_body_process/content_quota_usage.go` | 流式/非流式 `image_count` 累积 | +| `bfe-access-pb/bfe_access_pb/bfe_access.proto` | 访问日志新增 `ai_mode`、`ai_image_count` | +| `bfe/bfe_modules/mod_access_pb3/request_log.go` | 序列化 `ai_mode`、`ai_image_count` | +| `bfe/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go` | 新增图像生成计费集成测试 | +| `bfe/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go` | 新增图像生成访问日志字段集成测试 | +| `bfe/docs/zh_cn/modifications/2026-08-22-image-generation-billing-support/design-changes.md` | 本设计变更文档 | + +--- + +## 12. 兼容性与风险 + +### 12.1 兼容性 + +- 未配置 `output_cost_per_image` 的模型行为完全不变。 +- 无法识别 mode 的请求默认按 `chat` 处理,现有计费逻辑不变。 +- Token 配额(`total_token`)在 chat 模式下扣减逻辑不变。 +- Cache / audio 计费逻辑不变。 +- Redis key 结构、配置格式均不发生改变。 + +### 12.2 风险与缓解 + +| 风险 | 缓解措施 | +|---|---| +| 后端返回异常 `image_count` 导致费用为负 | `calcImageGenerationCost` 中校验并截断为 `0` | +| 响应无 usage 且请求未传 `n` | 默认按 `1` 张计费,避免漏计 | +| 路径推断 mode 不准确 | 默认 fallback 为 `chat`;后续可扩展 cluster/provider 显式配置 | +| 图像生成耗时超过默认超时 | 为对应 cluster 配置更大的超时参数 | +| proto 字段变更需重新生成 | 修改后执行 `bfe-access-pb/build.sh` | + +--- + +## 13. 参考资料 + +- `document-ai-gateway/迭代系统设计/v0.5/计费能力扩展/bfe-image-generation-billing-support-analysis.md` +- `document-ai-gateway/迭代系统设计/v0.4/quota-rmb-support/bfe-changes-for-rmb-quota.md` +- `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` +- `bfe/bfe_modules/mod_body_process/content_quota_usage.go` +- `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` +- `bfe/bfe_basic/request_ai_basic.go` +- `bfe/bfe_server/http_conn.go` diff --git a/docs/zh_cn/sys_design/ai_access_log_fields.md b/docs/zh_cn/sys_design/ai_access_log_fields.md index 045846fd7..b587a0a60 100644 --- a/docs/zh_cn/sys_design/ai_access_log_fields.md +++ b/docs/zh_cn/sys_design/ai_access_log_fields.md @@ -19,7 +19,7 @@ BFE 作为 AI 网关,需要把请求在认证、路由、转发、计费等各 ## 2. 字段总览 -AI 可观测字段统一占用 `bfe-access-pb` 的 701-900 编号区间,当前已定义 24 个字段: +AI 可观测字段统一占用 `bfe-access-pb` 的 701-900 编号区间,当前已定义 26 个字段: | 编号 | 字段名 | 类型 | 说明 | 采集模块 | |------|--------|------|------|----------| @@ -38,12 +38,14 @@ AI 可观测字段统一占用 `bfe-access-pb` 的 701-900 编号区间,当前 | 713 | `ai_auth_reject_quota_plans` | `repeated string` | 拒绝时余额不足的 Quota Plan ID 列表 | `mod_ai_token_auth` | | 714 | `ai_provider` | `string` | 上游模型提供商标识 | `bfe_server/reverseproxy.go` | | 715 | `ai_retry_count` | `uint32` | 模型调用层 key-level 重试次数 | `bfe_server/reverseproxy.go` | +| 716 | `ai_mode` | `string` | AI 请求模式,如 `chat`、`image_generation` | `bfe_server/http_conn.go` | | 761 | `ai_cost_value` | `int64` | 估算成本(定点整数,精度由币种决定) | `mod_ai_token_auth` | | 762 | `ai_cost_currency` | `string` | 成本币种,如 `RMB` / `USD` | `bfe_server/reverseproxy.go` | | 781 | `ai_cache_read_tokens` | `int64` | 从 cache 读取的 Token 数(已包含在 `ai_input_tokens` 中) | `mod_ai_token_auth` / `mod_body_process` | | 782 | `ai_cache_write_tokens` | `int64` | 写入 cache 的 Token 数(独立附加项) | `mod_ai_token_auth` / `mod_body_process` | | 783 | `ai_audio_input_tokens` | `int64` | 音频输入 Token 数(已包含在 `ai_input_tokens` 中) | `mod_ai_token_auth` / `mod_body_process` | | 784 | `ai_audio_output_tokens` | `int64` | 音频输出 Token 数(已包含在 `ai_output_tokens` 中) | `mod_ai_token_auth` / `mod_body_process` | +| 785 | `ai_image_count` | `int64` | 生成的图像张数(image_generation 模式) | `mod_ai_token_auth` / `mod_body_process` | | 801 | `ai_route_rule_hits` | `repeated AIRouteRuleHit` | 命中的 AI 路由规则列表 | `mod_ai_route` | | 802 | `ai_cluster_key_names` | `repeated ClusterKeyName` | 请求处理过程中尝试过的 (cluster, key) 列表 | `bfe_server/reverseproxy.go` | | 841 | `ai_auth_hit_quota_plans` | `repeated string` | 正常请求时命中的 Quota Plan ID 列表 | `mod_ai_token_auth` | @@ -54,12 +56,12 @@ AI 可观测字段统一占用 `bfe-access-pb` 的 701-900 编号区间,当前 |----------|------| | 701 - 713 | 已投入使用字段,保持现状,不再调整 | | 714 - 760 | 模型与请求基础信息(model、provider、stream、retry、cache 等) | -| 761 - 800 | Token 与成本计量(761-780 为普通 token/cost;781-790 为 cache/audio token 子项) | +| 761 - 800 | Token 与成本计量(761-780 为普通 token/cost;781-790 为 cache/audio/image 子项) | | 801 - 840 | 路由、转换与插件 | | 841 - 880 | 安全、合规与隐私 | | 881 - 900 | 厂商扩展与预留 | -> 说明:编号 781-790 已用于 `cache_read` / `cache_write` / `audio_input` / `audio_output` 等子项 Token 字段,后续新增子项可继续向 785-790 扩展。 +> 说明:编号 781-790 已用于 `cache_read` / `cache_write` / `audio_input` / `audio_output` / `image_count` 等子项字段,后续新增子项可继续向 786-790 扩展。 --- @@ -121,6 +123,7 @@ type AiBasicInfo struct { ClientKeyId string // 701 ai_apikey_id ClientModel string // 703 ai_requested_model TargetModel string // 704 ai_target_model + Mode string // 716 ai_mode Provider string // 714 ai_provider RetryCount uint32 // 715 ai_retry_count CostCurrency string // 762 ai_cost_currency @@ -140,7 +143,8 @@ type TokenUsage struct { CacheWriteTokens int64 // 782 ai_cache_write_tokens,独立附加项 AudioInputTokens int64 // 783 ai_audio_input_tokens,已包含在 PromptTokens 中 AudioOutputTokens int64 // 784 ai_audio_output_tokens,已包含在 CompletionTokens 中 - UsedQuota int64 // 708 ai_total_tokens + ImageCount int64 // 785 ai_image_count,图像生成模式下的图像张数 + UsedQuota int64 // 708 ai_total_tokens(image_generation 模式下等于 image_count) UsedCost int64 // 761 ai_cost_value } @@ -189,13 +193,15 @@ message AIRouteRuleHit { 在 `EnableAiGateway` 开启时初始化 `AiBasicInfo`: - 从 `Authorization` 头提取原始 API Key,写入 `ClientApiKey`(后续会被 `ClientKeyId` 覆盖); -- 从请求 JSON body 提取 `model` 字段,写入 `ClientModel` 和 `TargetModel`。 +- 从请求 JSON body 提取 `model` 字段,写入 `ClientModel` 和 `TargetModel`; +- 根据请求路径推断 `Mode`(如 `/v1/images/generations` → `image_generation`),用于访问日志 `ai_mode` 与后续定价匹配。 ### 5.2 `mod_ai_token_auth` - 在 `ValidateUserTokenByReq()` 中找到 Token 后,立即把 `Token.KeyId` 写入 `AiBasicInfo.ClientKeyId`,确保即使后续拒绝也能在日志中识别 key; - 通过 `SetTokenAuthContext()` 写入 `ApikeyTags` 和初始 `PromptTokens`; -- 在响应阶段解析 `usage`(含 `prompt_tokens`、`completion_tokens`、`cache_read_tokens`、`cache_write_tokens`、`audio_input_tokens`、`audio_output_tokens`)并估算 token,填充 `TokenUsage`; +- 在响应阶段解析 `usage`(含 `prompt_tokens`、`completion_tokens`、`cache_read_tokens`、`cache_write_tokens`、`audio_input_tokens`、`audio_output_tokens`、`image_count`)并估算 token,填充 `TokenUsage`; +- 对 `image_generation` 模式,在认证阶段预读请求体 `n` 字段作为 `ImageCount` 兜底; - 对 RMB 配额调用 `calcCostUnits()` 计算 `UsedCost`,支持 input/output、cache read/write、audio input/output 多价格子项拆分; - 在认证成功时记录 `HitQuotaPlans`,拒绝时记录 `RejectReason` 和 `RejectQuotaPlans`。 @@ -234,7 +240,7 @@ message AIRouteRuleHit { `reqAiInfoGen()` 负责把上述所有字段从 `AiBasicInfo`、`AiRateLimitHitInfo`、`AiRouteResult` 映射到 `RequestLog`: - 字段重命名:`AiApikey`→`AiApikeyId`、`AiMappedModel`→`AiTargetModel`、`AiPromptTokens`→`AiInputTokens`; -- 新增字段:`AiProvider`、`AiRetryCount`、`AiCostValue`、`AiCostCurrency`、`AiCacheReadTokens`、`AiCacheWriteTokens`、`AiAudioInputTokens`、`AiAudioOutputTokens`、`AiRouteRuleHits`、`AiClusterKeyNames`、`AiAuthHitQuotaPlans`。 +- 新增字段:`AiMode`、`AiProvider`、`AiRetryCount`、`AiCostValue`、`AiCostCurrency`、`AiCacheReadTokens`、`AiCacheWriteTokens`、`AiAudioInputTokens`、`AiAudioOutputTokens`、`AiImageCount`、`AiRouteRuleHits`、`AiClusterKeyNames`、`AiAuthHitQuotaPlans`。 --- @@ -249,14 +255,15 @@ message AIRouteRuleHit { ## 7. 测试与验证 1. **单元测试**:`bfe_modules/mod_access_pb3/request_log_test.go` 覆盖所有字段的赋值逻辑; -2. **集成测试**:`tests/integration/implementation/scenario-SC05-access-log-ai-fields/` 启动真实 BFE 进程,发送 AI 请求后解码 b2log,校验全部 24 个字段。 +2. **集成测试**:`tests/integration/implementation/scenario-SC05-access-log-ai-fields/` 启动真实 BFE 进程,发送 AI 请求后解码 b2log,校验全部 26 个字段(包括 `ai_mode`、`ai_image_count` 等图像生成场景字段)。 --- ## 8. 参考文档 - `bfe-access-pb/docs/protobuf.md` -- `bfe-access-pb/RELEASE_NOTES_v0.3.2.md` +- `bfe-access-pb/RELEASE_NOTES_v0.3.3.md` - `bfe/docs/zh_cn/modifications/2026-08-19-update-ai-access-log-fields/design-changes.md` - `bfe/docs/zh_cn/modifications/2026-08-22-audio-token-billing-support/design-changes.md` +- `bfe/docs/zh_cn/modifications/2026-08-22-image-generation-billing-support/design-changes.md` - `bfe/tests/integration/测试设计文档/scenario-SC05-AI访问日志字段校验/场景说明.md` diff --git a/docs/zh_cn/sys_design/rmb_quota.md b/docs/zh_cn/sys_design/rmb_quota.md index a8776e577..57417a4cd 100644 --- a/docs/zh_cn/sys_design/rmb_quota.md +++ b/docs/zh_cn/sys_design/rmb_quota.md @@ -17,6 +17,8 @@ v0.5 进一步引入 **cache** 与 **音频 token** 子项计费:后端返回的 `usage` 中可能包含 `cache_read_tokens`、`cache_write_tokens`、`audio_input_tokens`、`audio_output_tokens`,BFE 需要把这些子项从 `prompt_tokens` / `completion_tokens` 中剥离,并按各自价格分别计费。 +v0.6 引入 **图像生成按次计费**:图像生成模型(如 `flux-2-pro`)不按 Token 计费,而是按实际生成的图像张数计费。`AIConf.ModelTable` 中新增 `output_cost_per_image` 价格字段,响应 `usage` 中新增 `image_count` 字段,BFE 按 `image_count × output_cost_per_image` 计算成本。请求路径 `/v1/images/generations` 被识别为 `image_generation` 模式,与 `chat` 模式使用独立的定价条目。 + ### 1.2 目标 1. 配置层沿用 `AIConf.ModelTable`,价格以 `Prices` map(元/Token)下发,BFE 加载时转换为 1e-8 元/Token 定点整数; @@ -24,7 +26,8 @@ v0.5 进一步引入 **cache** 与 **音频 token** 子项计费:后端返回 3. `mod_ai_token_auth.QuotaPlan` 增加 `Unit`,`Deduct` / `HasBalance` 支持 RMB; 4. 新增共享库 `go-lib/quota`,提供 RMB 定点数转换,供 ai-gateway-api 与 BFE 共同引用; 5. Redis Lua 支持 RMB 扣减脚本,当前暂时使用单 Key 定点数方案; -6. 支持按 `cache_read_tokens` / `cache_write_tokens` / `audio_input_tokens` / `audio_output_tokens` 子项拆分计费,并与普通 input/output 价格共存。 +6. 支持按 `cache_read_tokens` / `cache_write_tokens` / `audio_input_tokens` / `audio_output_tokens` 子项拆分计费,并与普通 input/output 价格共存; +7. 支持图像生成模型按 `image_count` 与 `output_cost_per_image` 计费,并按请求路径识别 `mode`。 ## 2. 设计原则 @@ -74,7 +77,7 @@ type ModelPrice struct { Capabilities []string SupportedParameters []string Limits map[string]interface{} - Prices map[string]float64 // 价格对象,支持 input/output、cache_read/cache_creation、audio_input/audio_output 等 + Prices map[string]float64 // 价格对象,支持 input/output、cache_read/cache_creation、audio_input/audio_output、output_cost_per_image 等 Metadata map[string]interface{} } @@ -101,7 +104,7 @@ type AIConf struct { ### 4.3 校验规则 1. `ModelTable.Currency` 当前仅允许 `"RMB"`。 -2. `ModelPrice.Prices` 中 `input_cost_per_token`、`output_cost_per_token`、`cache_read_input_token_cost`、`cache_creation_input_token_cost`、`input_cost_per_audio_token`、`output_cost_per_audio_token` 均必须 `>= 0`(未配置时按 `0` 处理)。 +2. `ModelPrice.Prices` 中 `input_cost_per_token`、`output_cost_per_token`、`cache_read_input_token_cost`、`cache_creation_input_token_cost`、`input_cost_per_audio_token`、`output_cost_per_audio_token`、`output_cost_per_image` 均必须 `>= 0`(未配置时按 `0` 处理)。 3. `Model` 为具体模型名;`Mode` 如 `"chat"`。 4. 同一个 `Mode` 下,`Model` 不能重复。 5. 加载时构建二维索引 `priceIndex[model][mode]`,便于运行时 O(1) 查询。 @@ -129,8 +132,9 @@ func buildModelTableIndex(table *ModelTable) error { cacheWrite := price.Prices["cache_creation_input_token_cost"] audioInput := price.Prices["input_cost_per_audio_token"] audioOutput := price.Prices["output_cost_per_audio_token"] + outputCostPerImage := price.Prices["output_cost_per_image"] if input < 0 || output < 0 || cacheRead < 0 || cacheWrite < 0 || - audioInput < 0 || audioOutput < 0 { + audioInput < 0 || audioOutput < 0 || outputCostPerImage < 0 { return fmt.Errorf("negative price for model %s", price.Model) } price.Prices["input_cost_per_token_int"] = float64(quota.RmbToFixedPoint(input)) @@ -139,6 +143,7 @@ func buildModelTableIndex(table *ModelTable) error { price.Prices["cache_creation_input_token_cost_int"] = float64(quota.RmbToFixedPoint(cacheWrite)) price.Prices["input_cost_per_audio_token_int"] = float64(quota.RmbToFixedPoint(audioInput)) price.Prices["output_cost_per_audio_token_int"] = float64(quota.RmbToFixedPoint(audioOutput)) + price.Prices["output_cost_per_image_int"] = float64(quota.RmbToFixedPoint(outputCostPerImage)) // 2. 构建 model -> mode 二维索引 if table.priceIndex[price.Model] == nil { @@ -202,7 +207,8 @@ type TokenUsage struct { CacheWriteTokens int64 // 写入 cache 的 Token 数,独立附加项 AudioInputTokens int64 // 音频输入 Token 数,已包含在 PromptTokens 中 AudioOutputTokens int64 // 音频输出 Token 数,已包含在 CompletionTokens 中 - UsedQuota int64 // 已用 Token 配额(unit=total_token 时使用) + ImageCount int64 // 生成的图像张数(image_generation 模式使用) + UsedQuota int64 // 已用 Token 配额(unit=total_token 时使用;image_generation 模式下为 image_count) UsedCost int64 // 已用 RMB 成本,1 单位 = 1e-8 元(unit=RMB 时使用) } ``` @@ -280,11 +286,35 @@ func SetTokenAuthContext(req *bfe_basic.Request, tok *Token, promptToken int64, } ``` -### 7.3 响应阶段:`tokenReadResponseHandler` +### 7.3 请求 mode 识别 + +`bfe_server/http_conn.go` 在初始化 `AiBasicInfo` 后,根据请求路径推断请求 mode: + +```go +aiMeta.Mode = bfe_basic.DetectModeFromPath(request.HttpRequest.URL.Path) +``` + +`bfe_basic.DetectModeFromPath` 支持常见 OpenAI 风格路径: + +| 路径前缀 | mode | +|----------|------| +| `/v1/images/generations` | `image_generation` | +| `/v1/images/edits` | `image_edit` | +| `/v1/chat/completions` | `chat` | +| `/v1/completions` | `completion` | +| `/v1/embeddings` | `embedding` | +| `/v1/audio/speech` | `audio_speech` | +| `/v1/audio/transcriptions` | `audio_transcription` | +| `/v1/rerank` | `rerank` | +| 其他 | 默认 `chat` | + +mode 用于后续定价匹配(`(model, mode)` 二维索引)和访问日志输出。 + +### 7.4 响应阶段:`tokenReadResponseHandler` `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` -响应阶段负责从响应体中提取 `usage`,或在未返回 `usage` 时按响应体长度估算 Token 数。对于非流式响应,`ContentLength >= 0` 时可直接读取完整响应体: +响应阶段负责从响应体中提取 `usage`,或在未返回 `usage` 时按响应体长度估算 Token 数。对于图像生成响应,优先读取 `usage.image_count`,未返回时统计响应 `data` 数组长度,仍无则兜底请求体 `n` 字段(默认 1)。对于非流式响应,`ContentLength >= 0` 时可直接读取完整响应体: ```go func (m *ModuleAITokenAuth) tokenReadResponseHandler(req *bfe_basic.Request, res *bfe_http.Response) int { @@ -308,7 +338,7 @@ func (m *ModuleAITokenAuth) tokenReadResponseHandler(req *bfe_basic.Request, res } ``` -> 说明:旧实现中 RMB 成本在此阶段计算,导致流式响应(`ContentLength = -1`)无法计费。当前实现已将成本计算移到请求结束阶段,见 7.4。 +> 说明:旧实现中 RMB 成本在此阶段计算,导致流式响应(`ContentLength = -1`)无法计费。当前实现已将成本计算移到请求结束阶段,见 7.5。 #### 流式响应的 Token 用量收集 @@ -320,7 +350,7 @@ func (m *ModuleAITokenAuth) tokenReadResponseHandler(req *bfe_basic.Request, res 因此,到请求结束阶段,`tokenUsage.PromptTokens` 和 `tokenUsage.CompletionTokens` 已经就绪,无论流式还是非流式都可以统一计算 RMB 成本。 -### 7.4 请求结束阶段:`tokenRequestFinishHandler` +### 7.5 请求结束阶段:`tokenRequestFinishHandler` `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` @@ -376,9 +406,9 @@ func (m *ModuleAITokenAuth) tokenRequestFinishHandler(req *bfe_basic.Request, re } ``` -### 7.5 成本计算辅助方法 +### 7.6 成本计算辅助方法 -新增方法(位于 `mod_ai_token_auth`): +新增方法(位于 `mod_ai_token_auth`)。成本计算按 `AiBasicInfo.Mode` 分支: ```go func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe_basic.ServerDataConfInterface, usage *bfe_basic.TokenUsage) int64 { @@ -389,6 +419,10 @@ func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe clusterName := req.Route.ClusterName targetModel := aiMeta.TargetModel + mode := aiMeta.Mode + if mode == "" { + mode = bfe_basic.ModeChat + } if clusterName == "" || targetModel == "" { return 0 } @@ -402,16 +436,40 @@ func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe return 0 } - entry := cluster_conf.LookupModelPrice(cluster.AIConf.ModelTable, targetModel, "chat") + entry := cluster_conf.LookupModelPrice(cluster.AIConf.ModelTable, targetModel, mode) if entry == nil { - log.Logger.Warn("model price not found for cluster %s model %s", clusterName, targetModel) + log.Logger.Warn("model price not found for cluster %s model %s mode %s", clusterName, targetModel, mode) return 0 } + switch mode { + case bfe_basic.ModeImageGeneration: + return calcImageGenerationCost(entry, usage) + default: + return calcChatCost(entry, usage) + } +} + +func calcImageGenerationCost(entry *cluster_conf.ModelPrice, usage *bfe_basic.TokenUsage) int64 { + imageCount := usage.ImageCount + if imageCount < 0 { + imageCount = 0 + } + + costPerImage := int64(entry.Prices[cluster_conf.PriceOutputCostPerImageInt]) + if costPerImage < 0 { + log.Logger.Warn("invalid model price for image generation model %s", entry.Model) + return 0 + } + + return imageCount * costPerImage +} + +func calcChatCost(entry *cluster_conf.ModelPrice, usage *bfe_basic.TokenUsage) int64 { inputCost := int64(entry.Prices[cluster_conf.PriceInputCostPerTokenInt]) outputCost := int64(entry.Prices[cluster_conf.PriceOutputCostPerTokenInt]) if inputCost < 0 || outputCost < 0 { - log.Logger.Warn("invalid model price for cluster %s model %s", clusterName, targetModel) + log.Logger.Warn("invalid model price for model %s", entry.Model) return 0 } @@ -500,9 +558,26 @@ func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe } ``` -#### cache/audio 计费拆分公式 +#### 图像生成计费公式 + +`image_generation` 模式下成本仅与图像张数相关: + +``` +image_count = usage.image_count + ?? len(response.data) + ?? request.n + ?? 1 + +cost = image_count * output_cost_per_image +``` + +- 优先读取响应 `usage.image_count`; +- 未返回时统计响应 `data` 数组长度(OpenAI 风格图像生成响应); +- 仍无则兜底读取请求体 `n` 字段,未传时默认 `1`。 + +#### chat 模式 cache/audio 计费拆分公式 -在 `calcCostUnits` 中,RMB 成本按如下优先级拆分: +在 `calcChatCost` 中,RMB 成本按如下优先级拆分: 1. **Cache read 从 prompt 中剥离**:若配置了 `cache_read_input_token_cost`,则 `normal_input = prompt_tokens - cache_read_tokens`。 2. **Audio input 从剩余 normal input 中剥离**:若配置了 `input_cost_per_audio_token`,则 `audio_input_tokens` 按 audio 价格计费,其余仍按普通 input 价格计费。 @@ -517,7 +592,7 @@ func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe - 因此这里拿到的 `clusterName` 和 `targetModel` 就是计费所需的实际值。 - 价格到定点整数的转换在配置加载阶段通过 `go-lib/quota` 完成,运行时 `calcCostUnits` 只处理整数,保证 Redis Lua 不接触浮点。 -### 7.6 定价匹配逻辑 +### 7.7 定价匹配逻辑 ```go func lookupModelPrice(table *cluster_conf.ModelTable, model, mode string) *cluster_conf.ModelPrice { @@ -532,7 +607,7 @@ func lookupModelPrice(table *cluster_conf.ModelTable, model, mode string) *clust } ``` -索引在配置加载阶段构建,运行时按 `(model, mode)` 精确查询,为 O(1)。未命中时返回 `nil`,由调用方决定是否按 `0` 成本处理。 +索引在配置加载阶段构建,运行时按 `(model, mode)` 精确查询,为 O(1)。`mode` 由请求路径推断(如 `/v1/images/generations` → `image_generation`,`/v1/chat/completions` → `chat`),未识别时默认 `chat`。未命中时返回 `nil`,由调用方决定是否按 `0` 成本处理。 ## 8. Redis Lua 脚本改造 @@ -669,6 +744,20 @@ return {yuan, frac} "input_cost_per_audio_token": 0.00002, "output_cost_per_audio_token": 0.00004 } + }, + { + "Provider": "mock-provider", + "Model": "flux-2-pro", + "BaseModel": "flux-2-pro", + "Mode": "image_generation", + "Capabilities": ["image_generation"], + "SupportedParameters": ["prompt", "n", "size"], + "Limits": { + "context_window": 128000 + }, + "Prices": { + "output_cost_per_image": 0.03 + } } ] } @@ -676,7 +765,7 @@ return {yuan, frac} } ``` -> 上例中 `input_cost_per_token = 0.000001` 元/Token,换算为固定点整数即 `100`(= 0.000001 * 1e8),表示 **0.1 元 / 百万 Token**;`cache_read_input_token_cost` 等子项价格同样通过 `quota.RmbToFixedPoint` 转换为定点整数。 +> 上例中 `input_cost_per_token = 0.000001` 元/Token,换算为固定点整数即 `100`(= 0.000001 * 1e8),表示 **0.1 元 / 百万 Token**;`cache_read_input_token_cost` 等子项价格同样通过 `quota.RmbToFixedPoint` 转换为定点整数;`output_cost_per_image = 0.03` 元/张,换算为定点整数 `3000000`。 ## 10. 测试建议 @@ -686,7 +775,8 @@ return {yuan, frac} - `calcCostUnits`:正常计算、ModelTable 缺失、模型未命中、价格转换精度; - 覆盖 cache read/write 与 audio input/output 子项拆分; - 覆盖子项用量大于总量时的 clamp 行为; - - 覆盖未配置子项价格时回退到普通 input/output 价格的行为。 + - 覆盖未配置子项价格时回退到普通 input/output 价格的行为; + - 覆盖 `image_generation` 模式按 `image_count × output_cost_per_image` 计费,以及未配置 `output_cost_per_image` 时按 0 成本处理。 2. **Lua 脚本测试** - 单 Key 定点数方案:验证扣减、余额归零、负数不溢出。 @@ -697,7 +787,8 @@ return {yuan, frac} - 测试 `ModelMapping` 场景:请求模型是 `gpt-4`,实际后端模型是 `deepseek-chat`,验证按 `deepseek-chat` 的价格计费。 - 测试 fallback 场景:请求最终 fallback 到另一个 cluster,验证按最终 cluster + target_model 计费。 - 测试流式(SSE)场景:请求体带 `stream: true`,后端返回 SSE 并在最后一个 chunk 中携带 `usage`,验证 RMB 配额仍能正确扣减。 - - 测试 cache/audio 子项计费场景:后端返回 `usage.cache_read_tokens`、`usage.cache_write_tokens`、`usage.audio_input_tokens`、`usage.audio_output_tokens`,验证成本按各子项价格拆分计算。 + - 测试 cache/audio 子项计费场景:后端返回 `usage.cache_read_tokens`、`usage.cache_write_tokens`、`usage.audio_input_tokens`、`usage.audio_output_tokens`,验证成本按各子项价格拆分计算; + - 测试图像生成按次计费场景:请求 `/v1/images/generations`,`ModelTable` 配置 `output_cost_per_image`,后端返回 `usage.image_count`,验证 RMB 配额按 `image_count × output_cost_per_image` 扣减,且 `total_token` 配额按 `image_count` 扣减。 ## 11. 兼容性与注意事项 @@ -708,6 +799,6 @@ return {yuan, frac} - 本次请求不对该 RMB 配额进行扣减(相当于按 `0` 成本处理); - 具体是否拒绝请求,需产品进一步确认。 4. **与多 Key 改造的关系**:`AIConf.Keys` 与 `ModelTable` 相互独立,可并行下发、独立解析。 -5. **流式响应计费**:RMB 成本在请求结束阶段计算,依赖 `mod_body_process`(或其他响应处理模块)在流式传输过程中填充 `PromptTokens` / `CompletionTokens` / `CacheReadTokens` / `CacheWriteTokens` / `AudioInputTokens` / `AudioOutputTokens`。生产环境若启用流式计费,需确保 `mod_body_process` 已加载。 +5. **流式响应计费**:RMB 成本在请求结束阶段计算,依赖 `mod_body_process`(或其他响应处理模块)在流式传输过程中填充 `PromptTokens` / `CompletionTokens` / `CacheReadTokens` / `CacheWriteTokens` / `AudioInputTokens` / `AudioOutputTokens` / `ImageCount`。生产环境若启用流式计费,需确保 `mod_body_process` 已加载。 6. **模块顺序建议**:`mod_ai_token_auth` 的 `HandleReadResponse` 不再负责 RMB 成本计算,因此对模块加载顺序的敏感度降低;但仍建议保持 `mod_ai_token_auth` 在 `mod_body_process` 之前注册,以便非流式场景下 token 用量解析逻辑保持一致。 7. **旧字段清理**:`AIConf.Key` 已移除,统一使用 `AIConf.Keys`。 diff --git a/go.mod b/go.mod index 8d2b16705..4da92935f 100644 --- a/go.mod +++ b/go.mod @@ -70,7 +70,7 @@ require ( require ( github.com/HdrHistogram/hdrhistogram-go v1.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/bfenetworks/bfe-access-pb v0.3.2 + github.com/bfenetworks/bfe-access-pb v0.3.3 github.com/bfenetworks/bfe-mock-waf v0.1.0 github.com/bfenetworks/bwi v0.1.2 github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index 5e5a123c3..f1f5919b2 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bfenetworks/bfe-access-pb v0.3.2 h1:tMU5xQRk2tZ98ZS+uUB8idRhN0cEVviAlzpeHnilYEg= -github.com/bfenetworks/bfe-access-pb v0.3.2/go.mod h1:3qunybhMV5hEeT/2qDwbchxo4t/9LPKEvwwZkmekRho= +github.com/bfenetworks/bfe-access-pb v0.3.3 h1:8RjUhtoz32gbVQ2qlCdD7wjZfRFk+uYaTwyR4jHPWFg= +github.com/bfenetworks/bfe-access-pb v0.3.3/go.mod h1:3qunybhMV5hEeT/2qDwbchxo4t/9LPKEvwwZkmekRho= github.com/bfenetworks/bfe-mock-waf v0.1.0 h1:dTd540S3nv6qNlG6lLC0F8qx3gyo5WB4wnci3+d4J78= github.com/bfenetworks/bfe-mock-waf v0.1.0/go.mod h1:MWZHbihiRQXpoUCvY1l18s2bfOBWx4N4pghxBt+xUv0= github.com/bfenetworks/bwi v0.1.2 h1:3AcCzUjyzKm+FeLgTIVg58u+SUdEVZdJbQM58Ezwjcg= diff --git a/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go b/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go index ad02a6b9f..5d34c3046 100644 --- a/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go +++ b/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go @@ -53,8 +53,10 @@ var cacheBody = []byte(`{"model":"claude-opus-4-6"}`) var cacheStreamBody = []byte(`{"model":"claude-opus-4-6","stream":true}`) var audioBody = []byte(`{"model":"gpt-audio-1.5"}`) var audioStreamBody = []byte(`{"model":"gpt-audio-1.5","stream":true}`) +var imageGenerationBody = []byte(`{"model":"flux-2-pro","n":2}`) var usageResponse = `{"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}}` +var imageGenerationUsageResponse = `{"usage":{"image_count":2}}` // SSE format: final chunk contains usage. The trailing blank line is required. var streamUsageResponse = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n" + @@ -181,7 +183,11 @@ func (e *testEnv) logBFEAccess() { } func (e *testEnv) sendRequest(host string, body []byte) (*http.Response, string, error) { - url := fmt.Sprintf("http://127.0.0.1:%d%s", e.bfePort, apiPath) + return e.sendRequestToPath(host, apiPath, body) +} + +func (e *testEnv) sendRequestToPath(host string, path string, body []byte) (*http.Response, string, error) { + url := fmt.Sprintf("http://127.0.0.1:%d%s", e.bfePort, path) req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, "", err @@ -285,6 +291,25 @@ func fallbackRMBAIConf() *cluster_conf.AIConf { return conf } +func imageGenerationAIConf() *cluster_conf.AIConf { + conf := defaultRMBAIConf() + conf.ModelTable.Models = append(conf.ModelTable.Models, cluster_conf.ModelPrice{ + Provider: "mock-provider", + Model: "flux-2-pro", + BaseModel: "flux-2-pro", + Mode: "image_generation", + Capabilities: []string{"image_generation"}, + SupportedParameters: []string{"prompt", "n", "size"}, + Limits: map[string]interface{}{ + "context_window": 128000, + }, + Prices: map[string]float64{ + "output_cost_per_image": 0.03, + }, + }) + return conf +} + func noTableAIConf() *cluster_conf.AIConf { return &cluster_conf.AIConf{ Type: 0, @@ -708,3 +733,39 @@ func TestTC11_RMBQuotaDeduction_Audio_Streaming(t *testing.T) { t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) } } + +// TestTC12 verifies RMB quota deduction for image generation by image count. +func TestTC12_RMBQuotaDeduction_ImageGeneration(t *testing.T) { + aiConfs := map[string]*cluster_conf.AIConf{ + clusterRMB: imageGenerationAIConf(), + } + e := newTestEnv(t, aiConfs, []common.QuotaPlan{rmbQuotaPlan(10000000000)}) + defer e.Close() + + e.redis.SetQuota(redisKeyRMB, 10000000000) + e.backends[clusterRMB].Body = imageGenerationUsageResponse + + resp, body, err := e.sendRequestToPath(apiHost, "/v1/images/generations", imageGenerationBody) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if e.backends[clusterRMB].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterRMB, e.backends[clusterRMB].Hits()) + } + + // Wait for async redis deduction after response finishes. + time.Sleep(500 * time.Millisecond) + remaining := e.redis.GetQuota(redisKeyRMB) + // cost = 2 * 0.03 RMB = 2 * 3000000 fixed-point units + want := int64(10000000000 - 2*3000000) + if remaining != want { + e.logBFEException() + e.logBFEAccess() + t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) + } +} diff --git a/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go b/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go index 64f0c5e33..37e7118b3 100644 --- a/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go +++ b/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go @@ -52,8 +52,10 @@ const ( var defaultBody = []byte(`{"model":"deepseek-chat"}`) var modelMappingBody = []byte(`{"model":"gpt-4"}`) var streamBody = []byte(`{"model":"deepseek-chat","stream":true}`) +var imageGenerationBody = []byte(`{"model":"flux-2-pro","n":2}`) var usageResponse = `{"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}}` +var imageGenerationUsageResponse = `{"usage":{"image_count":2}}` var cacheUsageResponse = `{"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150,"cache_read_tokens":30,"cache_write_tokens":20}}` var audioUsageResponse = `{"usage":{"prompt_tokens":4000,"completion_tokens":500,"total_tokens":4500,"audio_input_tokens":1000,"audio_output_tokens":200}}` @@ -175,7 +177,11 @@ func (e *testEnv) logBFEException() { } func (e *testEnv) sendRequest(host string, body []byte) (*http.Response, string, error) { - url := fmt.Sprintf("http://127.0.0.1:%d%s", e.bfePort, apiPath) + return e.sendRequestToPath(host, apiPath, body) +} + +func (e *testEnv) sendRequestToPath(host string, path string, body []byte) (*http.Response, string, error) { + url := fmt.Sprintf("http://127.0.0.1:%d%s", e.bfePort, path) req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, "", err @@ -306,6 +312,25 @@ func audioRMBAIConf() *cluster_conf.AIConf { return conf } +func imageGenerationAIConf() *cluster_conf.AIConf { + conf := defaultRMBAIConf() + conf.ModelTable.Models = append(conf.ModelTable.Models, cluster_conf.ModelPrice{ + Provider: "mock-provider", + Model: "flux-2-pro", + BaseModel: "flux-2-pro", + Mode: "image_generation", + Capabilities: []string{"image_generation"}, + SupportedParameters: []string{"prompt", "n", "size"}, + Limits: map[string]interface{}{ + "context_window": 128000, + }, + Prices: map[string]float64{ + "output_cost_per_image": 0.03, + }, + }) + return conf +} + func noTableAIConf() *cluster_conf.AIConf { return &cluster_conf.AIConf{ Type: 0, @@ -923,3 +948,44 @@ func TestTC09_AudioTokenFields(t *testing.T) { assertInt64Field(t, reqLog.AiCostValue, "ai_cost_value", 3951700) assertStringField(t, reqLog.AiCostCurrency, "ai_cost_currency", "RMB") } + +// TestTC10 verifies ai_mode, ai_image_count and image-generation cost. +func TestTC10_ImageGenerationFields(t *testing.T) { + aiConfs := map[string]*cluster_conf.AIConf{ + clusterRMB: imageGenerationAIConf(), + } + e := newTestEnv(t, aiConfs, []common.QuotaPlan{rmbQuotaPlan(10000000000)}, false) + defer e.Close() + + e.redis.SetQuota(redisKeyRMB, 10000000000) + e.backends[clusterRMB].Body = imageGenerationUsageResponse + + resp, body, err := e.sendRequestToPath(apiHost, "/v1/images/generations", imageGenerationBody) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if e.backends[clusterRMB].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterRMB, e.backends[clusterRMB].Hits()) + } + + // Wait for access log to be flushed before stopping BFE. + time.Sleep(500 * time.Millisecond) + + e.stopBFE() + e.stopBFE = nil + + reqLog := e.mustFindSingleLog(e.accessLogs()) + assertStringField(t, reqLog.AiMode, "ai_mode", "image_generation") + assertStringField(t, reqLog.AiRequestedModel, "ai_requested_model", "flux-2-pro") + assertStringField(t, reqLog.AiTargetModel, "ai_target_model", "flux-2-pro") + assertInt64Field(t, reqLog.AiImageCount, "ai_image_count", 2) + assertInt64Field(t, reqLog.AiTotalTokens, "ai_total_tokens", 2) + // cost = 2 * 0.03 RMB = 2 * 3000000 fixed-point units + assertInt64Field(t, reqLog.AiCostValue, "ai_cost_value", 2*3000000) + assertStringField(t, reqLog.AiCostCurrency, "ai_cost_currency", "RMB") +} diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-09-Audio\346\250\241\345\236\213\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-09-Audio\346\250\241\345\236\213\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" index 8c8ebae70..bf608b65f 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-09-Audio\346\250\241\345\236\213\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-09-Audio\346\250\241\345\236\213\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" @@ -11,7 +11,7 @@ SC03 RMB 配额扣减 ## 版本声明 - `bfe`:当前源码版本 -- `bfe-access-pb`:`v0.3.2` +- `bfe-access-pb`:`v0.3.3` ## 测试目的 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-10-Audio\346\250\241\345\236\213\346\265\201\345\274\217\350\256\241\350\264\271.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-10-Audio\346\250\241\345\236\213\346\265\201\345\274\217\350\256\241\350\264\271.md" index a2a587bc5..ebb2dca38 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-10-Audio\346\250\241\345\236\213\346\265\201\345\274\217\350\256\241\350\264\271.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-10-Audio\346\250\241\345\236\213\346\265\201\345\274\217\350\256\241\350\264\271.md" @@ -11,7 +11,7 @@ SC03 RMB 配额扣减 ## 版本声明 - `bfe`:当前源码版本 -- `bfe-access-pb`:`v0.3.2` +- `bfe-access-pb`:`v0.3.3` ## 测试目的 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-12-ImageGeneration\346\250\241\345\236\213\346\214\211\346\254\241\350\256\241\350\264\271.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-12-ImageGeneration\346\250\241\345\236\213\346\214\211\346\254\241\350\256\241\350\264\271.md" new file mode 100644 index 000000000..174c2c9fe --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/TC-12-ImageGeneration\346\250\241\345\236\213\346\214\211\346\254\241\350\256\241\350\264\271.md" @@ -0,0 +1,74 @@ +# TC-12 ImageGeneration 模型按次计费 + +## 用例编号与名称 + +TC-12 ImageGeneration 模型按次计费 + +## 所属场景 + +SC03 RMB 配额扣减 + +## 版本声明 + +- `bfe`:当前源码版本 +- `bfe-access-pb`:`v0.3.3` + +## 测试目的 + +验证当请求路径为 `/v1/images/generations`、模型价格表中配置了 `output_cost_per_image` 时,BFE 按实际生成图像张数计费: + +``` +cost = image_count * output_cost_per_image +``` + +其中 `image_count` 优先从响应 `usage.image_count` 读取;若响应未返回,则兜底读取请求体中的 `n` 字段(默认 1)。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动,并预置 `quota:plan_rmb = 10000000000`。 +3. mock 后端 `cluster_rmb` 已启动,返回 200 与如下 body: + ```json + { + "usage": { + "image_count": 2 + } + } + ``` +4. 临时 BFE 配置已加载,`cluster_rmb` 的 `ModelTable` 包含模型 `flux-2-pro`: + - `Mode`: `image_generation` + - `output_cost_per_image`: `0.03` → 定点整数 `3000000` +5. `ak_user_a` 绑定 RMB 配额计划 `plan_rmb`。 + +## 配置构造 + +- `cluster_rmb.AIConf.ModelTable.Models` 增加模型 `flux-2-pro`: + - `Mode`: `image_generation` + - `output_cost_per_image`: `0.03` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `rmb.example.org` | +| Path | `/v1/images/generations` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"flux-2-pro","n":2}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_rmb` 收到 1 次命中(200)。 +- Redis 中 `quota:plan_rmb` 的余额按图像张数扣减: + - 扣减金额 = 2 * 3000000 = 6000000 + - 剩余 = `10000000000 - 6000000 = 9999400000` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" index ec018e004..85c031a42 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC03-RMB\351\205\215\351\242\235\346\211\243\345\207\217/\345\234\272\346\231\257\350\257\264\346\230\216.md" @@ -13,7 +13,10 @@ BFE v0.4 在 `mod_ai_token_auth` 中引入 RMB(人民币)配额支持。与 - cluster 未配置 `ModelTable` 或模型未命中时按 0 成本处理; - fallback 到另一个 cluster 后按最终 cluster + target_model 计费; - 含 cache 的模型按 cache 拆分公式计费(非流式与流式); -- 含音频的模型按音频 input/output 拆分公式计费(非流式与流式)。 +- 含音频的模型按音频 input/output 拆分公式计费(非流式与流式); +- 图像生成模型按实际生成张数 `image_count` 与 `output_cost_per_image` 计费(非流式)。 + +> 注:TC-12 编号跳过 TC-11,是为了与后续可能补充的流式图像生成测试例预留空间。 ## 2. 运行模式 @@ -118,6 +121,20 @@ BFE v0.4 在 `mod_ai_token_auth` 中引入 RMB(人民币)配额支持。与 "input_cost_per_audio_token": 0.00002288, "output_cost_per_audio_token": 0.00004576 } + }, + { + "Provider": "mock-provider", + "Model": "flux-2-pro", + "BaseModel": "flux-2-pro", + "Mode": "image_generation", + "Capabilities": ["image_generation"], + "SupportedParameters": ["prompt", "n", "size"], + "Limits": { + "context_window": 128000 + }, + "Prices": { + "output_cost_per_image": 0.03 + } } ] } @@ -139,6 +156,8 @@ BFE v0.4 在 `mod_ai_token_auth` 中引入 RMB(人民币)配额支持。与 > - `output_cost_per_token = 0.00000715` → 定点整数 `715` > - `input_cost_per_audio_token = 0.00002288` → 定点整数 `2288` > - `output_cost_per_audio_token = 0.00004576` → 定点整数 `4576` +> - `flux-2-pro` 配置图像生成单价: +> - `output_cost_per_image = 0.03` → 定点整数 `3000000`(0.03 元/张) ### 4.3 `cluster_fallback_rmb` 的 `AIConf` @@ -231,6 +250,7 @@ BFE v0.4 在 `mod_ai_token_auth` 中引入 RMB(人民币)配额支持。与 | TC-08 | Cache 模型流式计费 | SSE 最终 chunk 含 cache usage,按 cache 拆分公式扣减 | | TC-09 | Audio 模型非流式计费 | 后端返回含 `audio_input_tokens` / `audio_output_tokens` 的响应,按音频拆分公式扣减 | | TC-10 | Audio 模型流式计费 | SSE 最终 chunk 含 audio usage,按音频拆分公式扣减 | +| TC-12 | ImageGeneration 模型按次计费 | 请求 `/v1/images/generations`,后端返回 `usage.image_count = 2`,按 `2 * 0.03` 元扣减 | ## 7. 公共基础设施改造 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-09-Audio Token\345\255\227\346\256\265.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-09-Audio Token\345\255\227\346\256\265.md" index faabad857..b2bbacd17 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-09-Audio Token\345\255\227\346\256\265.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-09-Audio Token\345\255\227\346\256\265.md" @@ -11,7 +11,7 @@ SC05 AI 访问日志字段校验 ## 版本声明 - `bfe`:当前源码版本 -- `bfe-access-pb`:`v0.3.2` +- `bfe-access-pb`:`v0.3.3` ## 测试目的 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-10-ImageGeneration\345\255\227\346\256\265.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-10-ImageGeneration\345\255\227\346\256\265.md" new file mode 100644 index 000000000..d9c75e7e0 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/TC-10-ImageGeneration\345\255\227\346\256\265.md" @@ -0,0 +1,88 @@ +# TC-10 ImageGeneration 字段 + +## 用例编号与名称 + +TC-10 ImageGeneration 字段 + +## 所属场景 + +SC05 AI 访问日志字段校验 + +## 版本声明 + +- `bfe`:当前源码版本 +- `bfe-access-pb`:`v0.3.3` + +## 测试目的 + +验证请求图像生成接口时,`mod_access_pb3` 输出的 b2log 中 `ai_mode`、`ai_image_count`、`ai_total_tokens`、`ai_cost_value` 等字段被正确填充: + +- `ai_mode` 应为 `image_generation`; +- `ai_image_count` 等于响应 `usage.image_count`; +- `ai_total_tokens` 在图像生成模式下按 `image_count` 扣减; +- `ai_cost_value` 按 `image_count * output_cost_per_image` 计算。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动,并预置 `quota:plan_rmb = 10000000000`(100 元)。 +3. mock 后端 `cluster_rmb` 已启动,返回 200 与如下 body: + ```json + { + "usage": { + "image_count": 2 + } + } + ``` +4. 临时 BFE 配置已加载,`cluster_rmb` 配置 `Provider = "mock-provider"`、`ModelTable.Currency = "RMB"`,且价格表包含图像生成模型: + - `Model`: `flux-2-pro` + - `Mode`: `image_generation` + - `output_cost_per_image = 0.03` +5. `ak_user_a` 绑定 RMB 配额计划 `plan_rmb`。 +6. 启用 `mod_access_pb3`,b2log 输出到临时 `log/` 目录。 + +## 配置构造 + +- `cluster_rmb.AIConf`: + - `Provider`: `mock-provider` + - `ModelTable.Currency`: `RMB` + - `ModelTable.Models` 增加 `flux-2-pro`: + - `Mode`: `image_generation` + - `Prices.output_cost_per_image`: `0.03` +- `plan_rmb`: + - `Unit`: `RMB` + - `Quota`: `10000000000` + - `RedisKey`: `quota:plan_rmb` +- `ai_route.data` 中 `apikey_ak_user_a` 命中 `user_a-rmb`,target 为 `cluster_rmb`。 + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `rmb.example.org` | +| Path | `/v1/images/generations` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"flux-2-pro","n":2}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_rmb` 收到 1 次命中。 +- b2log 中存在 1 条 `RequestLog`,且字段满足: + - `ai_mode` = `"image_generation"` + - `ai_requested_model` = `"flux-2-pro"` + - `ai_target_model` = `"flux-2-pro"` + - `ai_image_count` = `2` + - `ai_total_tokens` = `2` + - `ai_cost_value` = `2 * 3000000 = 6000000` + - `ai_cost_currency` = `"RMB"` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" index 843f7004e..88ee4a0c6 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC05-AI\350\256\277\351\227\256\346\227\245\345\277\227\345\255\227\346\256\265\346\240\241\351\252\214/\345\234\272\346\231\257\350\257\264\346\230\216.md" @@ -8,12 +8,14 @@ - `ai_apikey_id` 记录 API Key 内部 ID,而非原始 key 值; - `ai_target_model` 反映路由/映射后的最终模型; +- `ai_mode` 反映请求路径推断出的 AI 请求模式; - `ai_provider`、`ai_cost_value`、`ai_cost_currency` 在 RMB 配额场景正确输出; - `ai_retry_count` 在多 Key 重试时正确累加; - `ai_route_rule_hits`、`ai_cluster_key_names`、`ai_auth_hit_quota_plans` 与请求行为一致; - `ai_auth_reject_reason`、`ai_auth_reject_quota_plans` 在配额拒绝场景正确输出; - `ai_cache_read_tokens`、`ai_cache_write_tokens` 在 cache 计费场景正确输出; -- `ai_audio_input_tokens`、`ai_audio_output_tokens` 在音频计费场景正确输出。 +- `ai_audio_input_tokens`、`ai_audio_output_tokens` 在音频计费场景正确输出; +- `ai_image_count` 在图像生成场景正确输出,且 `ai_total_tokens` 按图像张数扣减。 ## 2. 运行模式 @@ -82,6 +84,18 @@ "input_cost_per_token": 0.000001, "output_cost_per_token": 0.000002 } + }, + { + "Provider": "mock-provider", + "Model": "flux-2-pro", + "BaseModel": "flux-2-pro", + "Mode": "image_generation", + "Capabilities": ["image_generation"], + "SupportedParameters": ["prompt", "n", "size"], + "Limits": {"context_window": 128000}, + "Prices": { + "output_cost_per_image": 0.03 + } } ] } @@ -94,6 +108,7 @@ > - `Currency = "RMB"` 用于校验 `ai_cost_currency`。 > - `input_cost_per_token = 0.000001` 元/Token,定点整数为 `100`(1e-8 元/Token)。 > - `output_cost_per_token = 0.000002` 元/Token,定点整数为 `200`。 +> - `flux-2_pro` 配置图像生成单价 `output_cost_per_image = 0.03` 元/张,定点整数 `3000000`。 > - 配置两个 Key 并允许 `MaxRetries = 2`,用于校验 `ai_retry_count` 和 `ai_cluster_key_names`。 ### 4.3 `cluster_fallback_rmb` 的 `AIConf` @@ -182,6 +197,7 @@ | TC-07 | 限流命中字段 | 触发 RPM/TPM 限流,校验 `ai_rate_limit_hits` | | TC-08 | Cache Token 字段 | 后端返回含 `cache_read_tokens` / `cache_write_tokens` 的 usage,校验访问日志中 `ai_cache_read_tokens`、`ai_cache_write_tokens` 与 cache-aware 成本 | | TC-09 | Audio Token 字段 | 后端返回含 `audio_input_tokens` / `audio_output_tokens` 的 usage,校验访问日志中 `ai_audio_input_tokens`、`ai_audio_output_tokens` 与 audio-aware 成本 | +| TC-10 | ImageGeneration 字段 | 请求 `/v1/images/generations`,后端返回 `usage.image_count = 2`,校验 `ai_mode = image_generation`、`ai_image_count = 2`、`ai_total_tokens = 2` 与按次计费成本 | ## 7. 公共基础设施改造 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" index c597c6843..0c57b8a2b 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" @@ -4,9 +4,9 @@ | 项目 | 内容 | |------|------| -| 版本号 | v0.3.0 | -| 对应 BFE 行为方案 | `document-ai-gateway/BFE设计/v0.3.0/BFE的mod_ai_route集成测试方案/BFE的mod_ai_route集成测试方案v1.0.0.md` | -| 测试目标 | 验证 `bfe/mod_ai_route` 的多路由表查找、多 target 加权选择、多 fallback 降级等行为 | +| 版本号 | v0.4.0 | +| 对应 BFE 行为方案 | `document-ai-gateway/迭代系统设计/v0.5/计费能力扩展/bfe-image-generation-billing-support-analysis.md` | +| 测试目标 | 验证 `bfe/mod_ai_route` 的多路由表查找、多 target 加权选择、多 fallback 降级、RMB/图像生成按次计费、AI 访问日志字段等行为 | | 测试形态 | 仅启动真实 `bfe` 进程,不依赖 `ai-gateway-api` 与 `conf-agent` | ## 2. 测试范围 @@ -25,9 +25,9 @@ |----------|----------|------------|--------|--------------|--------------| | SC01 | 路由表查找与绑定 | 验证 `mod_ai_route` 在 apikey/entity/global 多级路由表中的搜索与回退顺序 | P0 | 10 | BFE 的 mod_ai_route 集成测试方案 v1.0.0 | | SC02 | 多 API-Key 轮换与重试 | 验证 `aiClusterInvoke()` 内部多 Key 加权选择、429 轮换、401/403 死亡、5xx 退避重试及与 cluster fallback 的边界 | P0 | 8 | BFE 多 API-Key 支持改造方案 | -| SC03 | RMB 配额扣减 | 验证 `mod_ai_token_auth` 对 RMB 配额的定价匹配、定点数扣减、余额预检、ModelMapping/Fallback 计费行为 | P0 | 6 | BFE RMB 配额支持 | +| SC03 | RMB 配额扣减 | 验证 `mod_ai_token_auth` 对 RMB 配额的定价匹配、定点数扣减、余额预检、ModelMapping/Fallback 计费行为,以及图像生成按次计费 | P0 | 11 | BFE RMB 配额支持、BFE 图像生成按次计费 | | SC04 | ProviderModel 前缀裁剪 | 验证 `AIConf.MatchPrefix` 与 `StripPrefix` 对请求模型名的裁剪行为 | P0 | 6 | BFE ProviderModel 前缀裁剪 | -| SC05 | AI 访问日志字段校验 | 验证 `mod_access_pb3` 输出的 AI 可观测字段完整性与正确性 | P0 | 5 | BFE 适配 bfe-access-pb AI 可观测字段升级 | +| SC05 | AI 访问日志字段校验 | 验证 `mod_access_pb3` 输出的 AI 可观测字段完整性与正确性,包括图像生成相关字段 | P0 | 10 | BFE 适配 bfe-access-pb AI 可观测字段升级、BFE 图像生成按次计费 | ## 4. 公共前置条件 @@ -84,6 +84,11 @@ | TC-04 | Token 与 RMB 配额共存 | P0 | | TC-05 | 无 ModelTable 按 0 成本处理 | P0 | | TC-06 | Fallback 后按最终 cluster 计费 | P0 | +| TC-07 | Cache 模型非流式计费 | P0 | +| TC-08 | Cache 模型流式计费 | P0 | +| TC-09 | Audio 模型非流式计费 | P0 | +| TC-10 | Audio 模型流式计费 | P0 | +| TC-12 | ImageGeneration 模型按次计费 | P0 | ### SC05 AI 访问日志字段校验 @@ -96,6 +101,9 @@ | TC-05 | Fallback 后 provider 与 cluster_key_names | P0 | | TC-06 | 流式响应字段 | P0 | | TC-07 | 限流命中字段 | P0 | +| TC-08 | Cache Token 字段 | P0 | +| TC-09 | Audio Token 字段 | P0 | +| TC-10 | ImageGeneration 字段 | P0 | ## 6. 运行方式 From d9725a4884ce997da5f7ad3f3f6bf2a1f8330b46 Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Sun, 23 Aug 2026 16:14:11 +0800 Subject: [PATCH 06/18] feat(bfe): support Anthropic Claude protocol forwarding and add integration tests - Detect request auth style (openai/anthropic) by path and headers - Inject x-api-key and anthropic-version for Anthropic-style requests - Reject requests when cluster AIConf.ModelProtocols does not match - Add ModelProtocols field to AIConf and parse it from cluster_conf.data - Add ai_protocol to protobuf access logs - Update sys_design and configuration docs for Claude protocol support - Add SC06 integration tests for OpenAI/Anthropic forwarding and mismatch rejection Signed-off-by: zhangmiao --- bfe_basic/request_ai_basic.go | 57 ++- .../cluster_conf/cluster_conf_load.go | 5 + bfe_modules/mod_access_pb3/request_log.go | 5 + .../mod_ai_token_auth/mod_ai_token_auth.go | 40 +- .../mod_ai_token_auth_test.go | 13 +- bfe_modules/mod_body_process/body_process.go | 16 + bfe_modules/mod_body_process/llm_util.go | 15 + bfe_server/reverseproxy.go | 41 +- .../server_data_conf/cluster_conf.data.md | 79 ++++ .../design-changes.md | 443 ++++++++++++++++++ docs/zh_cn/sys_design/ai_access_log_fields.md | 21 +- .../sys_design/claude_protocol_support.md | 353 ++++++++++++++ docs/zh_cn/sys_design/multi_api_key.md | 40 +- .../provider_model_prefix_routing.md | 2 + docs/zh_cn/sys_design/rmb_quota.md | 13 +- go.mod | 2 +- go.sum | 4 +- tests/integration/common/mock_backend.go | 28 +- .../sc06_claude_protocol_test.go | 428 +++++++++++++++++ .../testdata/bfe.conf | 36 ++ .../testdata/cluster_conf/gslb.data | 18 + .../testdata/mod_ai_route/ai_route.data | 52 ++ .../testdata/mod_ai_route/mod_ai_route.conf | 5 + .../testdata/server_data_conf/host_rule.data | 15 + .../testdata/server_data_conf/route_rule.data | 12 + .../testdata/server_data_conf/vip_rule.data | 8 + .../testdata/tls_conf/backend_rs/bfe_i_ca.crt | 23 + .../testdata/tls_conf/backend_rs/bfe_r_ca.crt | 22 + .../tls_conf/backend_rs/r_bfe_dev.crt | 85 ++++ .../tls_conf/backend_rs/r_bfe_dev_prv.pem | 27 ++ .../tls_conf/backend_rs/r_san_example.org.crt | 85 ++++ .../backend_rs/r_san_example.org_prv.pem | 27 ++ .../testdata/tls_conf/certs/example.crt | 19 + .../testdata/tls_conf/certs/example.key | 27 ++ .../tls_conf/client_ca/example_ca.crt | 19 + .../tls_conf/client_ca/example_ca.key | 27 ++ .../testdata/tls_conf/server_cert_conf.data | 12 + .../testdata/tls_conf/session_ticket_key.data | 4 + .../testdata/tls_conf/tls_rule_conf.data | 20 + ...72\346\231\257\350\257\264\346\230\216.md" | 287 ++++++++++++ 40 files changed, 2386 insertions(+), 49 deletions(-) create mode 100644 docs/zh_cn/modifications/2026-08-20-claude-protocol-support/design-changes.md create mode 100644 docs/zh_cn/sys_design/claude_protocol_support.md create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/sc06_claude_protocol_test.go create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/bfe.conf create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/cluster_conf/gslb.data create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/mod_ai_route/ai_route.data create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/mod_ai_route/mod_ai_route.conf create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/server_data_conf/host_rule.data create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/server_data_conf/route_rule.data create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/server_data_conf/vip_rule.data create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/bfe_i_ca.crt create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/bfe_r_ca.crt create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_bfe_dev.crt create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_san_example.org.crt create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/certs/example.crt create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/certs/example.key create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/client_ca/example_ca.crt create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/client_ca/example_ca.key create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/server_cert_conf.data create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/session_ticket_key.data create mode 100644 tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/tls_rule_conf.data create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/\345\234\272\346\231\257\350\257\264\346\230\216.md" diff --git a/bfe_basic/request_ai_basic.go b/bfe_basic/request_ai_basic.go index a99b4f529..a5023f11a 100644 --- a/bfe_basic/request_ai_basic.go +++ b/bfe_basic/request_ai_basic.go @@ -47,6 +47,13 @@ const ( ModeRealtime = "realtime" ) +// AI protocol/auth styles. +const ( + AuthStyleOpenAI = "openai" + AuthStyleAnthropic = "anthropic" + AuthStyleUnknown = "unknown" +) + type TokenUsage struct { PromptTokens int64 // number of tokens in the prompt (includes cache_read_tokens, audio_input_tokens) CompletionTokens int64 // number of tokens in the completion (includes audio_output_tokens) @@ -86,6 +93,7 @@ type AiBasicInfo struct { TargetModel string Mode string // request mode, e.g. chat, image_generation Provider string // upstream model provider, e.g. openai, deepseek + AuthStyle string // request protocol/auth style, e.g. openai, anthropic RetryCount uint32 // model invocation retry count (key-level retry) CostCurrency string // cost currency, e.g. RMB, USD tokenUsage TokenUsage @@ -116,17 +124,48 @@ func (aiinfo *AiBasicInfo) IsAllowEstimateToken() bool { } func GetApiKey(req *Request) string { - // get api key from Authorization header + // 1. prefer Authorization: Bearer for OpenAI style authHeader := req.HttpRequest.Header.Get("Authorization") - if authHeader == "" { - return "" + if authHeader != "" { + // remove "Bearer " prefix if exists + authHeader = strings.TrimPrefix(authHeader, "Bearer ") + authHeader = strings.TrimPrefix(authHeader, "sk-") + if ai := req.GetAiBasicInfo(); ai != nil { + ai.AuthStyle = AuthStyleOpenAI + } + return authHeader } - // remove "Bearer " prefix if exists - authHeader = strings.TrimPrefix(authHeader, "Bearer ") - authHeader = strings.TrimPrefix(authHeader, "sk-") + // 2. fallback to x-api-key for Anthropic style + if xApiKey := req.HttpRequest.Header.Get("x-api-key"); xApiKey != "" { + if ai := req.GetAiBasicInfo(); ai != nil { + ai.AuthStyle = AuthStyleAnthropic + } + return xApiKey + } - return authHeader + return "" +} + +// DetectAuthStyle infers the AI protocol/auth style from request characteristics. +// It is purely based on request path/headers and does not rely on user routing rules. +func DetectAuthStyle(req *Request) string { + if req == nil || req.HttpRequest == nil { + return AuthStyleUnknown + } + + path := req.HttpRequest.URL.Path + if strings.HasPrefix(path, "/v1/messages") { + return AuthStyleAnthropic + } + + // x-api-key without Authorization indicates Anthropic style + if req.HttpRequest.Header.Get("x-api-key") != "" && + req.HttpRequest.Header.Get("Authorization") == "" { + return AuthStyleAnthropic + } + + return AuthStyleOpenAI } // DetectModeFromPath infers the AI request mode from the request path. @@ -264,6 +303,8 @@ const ( CodeModelInternalError = "MODEL_INTERNAL_ERROR" CodeBackendTimeout = "BACKEND_TIMEOUT" + CodeProviderProtocolMismatch = "PROVIDER_PROTOCOL_MISMATCH" + CodeConfigLoadError = "CONFIG_LOAD_ERROR" CodeBackendUnavailable = "BACKEND_UNAVAILABLE" CodeInvalidRequestBody = "INVALID_REQUEST_BODY" @@ -322,6 +363,8 @@ var ErrorCodeToStatusCode = map[string]int{ CodeCostBudgetExhausted: 429, CodeGeoRestricted: 403, CodeTimeWindowRestricted: 403, + + CodeProviderProtocolMismatch: 400, } const ( diff --git a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go index 8eaf7da4d..556ca2171 100644 --- a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go +++ b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go @@ -178,6 +178,11 @@ type AIConf struct { // StripPrefix controls whether to strip MatchPrefix from the request model // field before forwarding to the backend. StripPrefix bool `json:"StripPrefix"` + + // ModelProtocols lists the model access protocols supported by the cluster's provider. + // It comes from ai-gateway-api provider.model_protocols, e.g. ["openai"], ["anthropic"], + // ["openai", "anthropic"]. Empty defaults to ["openai"] for backward compatibility. + ModelProtocols []string } const ( diff --git a/bfe_modules/mod_access_pb3/request_log.go b/bfe_modules/mod_access_pb3/request_log.go index 70656b4ae..fffceb300 100644 --- a/bfe_modules/mod_access_pb3/request_log.go +++ b/bfe_modules/mod_access_pb3/request_log.go @@ -405,6 +405,11 @@ func reqAiInfoGen(reqLog *bfe_access_pb3.RequestLog, req *bfe_basic.Request, res reqLog.AiProvider = proto.String(aiInfo.Provider) } + // Protocol/auth style + if aiInfo.AuthStyle != "" { + reqLog.AiProtocol = proto.String(aiInfo.AuthStyle) + } + // Stream reqLog.AiStream = proto.Bool(isStreamResponse(req, res)) diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go index ffd112bd8..233247ed1 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go @@ -17,7 +17,6 @@ package mod_ai_token_auth import ( "fmt" "net/url" - "strings" "github.com/bfenetworks/go-lib/log" "github.com/bfenetworks/go-lib/quota" @@ -126,6 +125,21 @@ func UpdateCtxByUsage(ctx *TokenAuthContext, data []byte) { imageCount = gjson.GetBytes(data, "data.#").Int() } + // Claude fallback: input_tokens / output_tokens / cache_read_input_tokens / cache_creation_input_tokens + if prompt == 0 && completion == 0 { + prompt = gjson.GetBytes(data, "usage.input_tokens").Int() + completion = gjson.GetBytes(data, "usage.output_tokens").Int() + if cacheRead == 0 { + cacheRead = gjson.GetBytes(data, "usage.cache_read_input_tokens").Int() + } + if cacheWrite == 0 { + cacheWrite = gjson.GetBytes(data, "usage.cache_creation_input_tokens").Int() + } + if used == 0 { + used = prompt + completion + } + } + tokenUsage := ctx.aiBasicInfo.GetTokenUsage() if used > 0 { tokenUsage.UsedQuota = used @@ -230,26 +244,22 @@ func (m *ModuleAITokenAuth) tokenRequestFinishHandler(req *bfe_basic.Request, re return bfe_module.BfeHandlerGoOn } -func SetApiKey(req *bfe_http.Request, apiKey string) { - // set api key to Authorization header +func SetApiKey(req *bfe_http.Request, apiKey string, authStyle string) { + // set api key according to protocol/auth style if apiKey == "" { return } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) -} -func GetApiKey(req *bfe_basic.Request) string { - // get api key from Authorization header - authHeader := req.HttpRequest.Header.Get("Authorization") - if authHeader == "" { - return "" + switch authStyle { + case bfe_basic.AuthStyleAnthropic: + req.Header.Set("x-api-key", apiKey) + default: + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) } +} - // remove "Bearer " prefix if exists - authHeader = strings.TrimPrefix(authHeader, "Bearer ") - authHeader = strings.TrimPrefix(authHeader, "sk-") - - return authHeader +func GetApiKey(req *bfe_basic.Request) string { + return bfe_basic.GetApiKey(req) } // found product handler diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go index d0fb81964..6cfe3ee60 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go @@ -269,15 +269,24 @@ func TestGetApiKey(t *testing.T) { func TestSetApiKey(t *testing.T) { req, _ := bfe_http.NewRequest(http.MethodGet, "http://example.com/", nil) - SetApiKey(req, "") + SetApiKey(req, "", bfe_basic.AuthStyleOpenAI) if req.Header.Get("Authorization") != "" { t.Error("empty api key should not set header") } - SetApiKey(req, "mykey") + SetApiKey(req, "mykey", bfe_basic.AuthStyleOpenAI) if got := req.Header.Get("Authorization"); got != "Bearer mykey" { t.Errorf("unexpected Authorization header: %s", got) } + + req2, _ := bfe_http.NewRequest(http.MethodGet, "http://example.com/", nil) + SetApiKey(req2, "mykey", bfe_basic.AuthStyleAnthropic) + if got := req2.Header.Get("x-api-key"); got != "mykey" { + t.Errorf("unexpected x-api-key header: %s", got) + } + if req2.Header.Get("Authorization") != "" { + t.Error("anthropic style should not set Authorization header") + } } func TestCalcReqUsedQuota(t *testing.T) { diff --git a/bfe_modules/mod_body_process/body_process.go b/bfe_modules/mod_body_process/body_process.go index 18c9c3dfe..e2eb91534 100644 --- a/bfe_modules/mod_body_process/body_process.go +++ b/bfe_modules/mod_body_process/body_process.go @@ -434,6 +434,22 @@ func (e *RawEvent) GetQuotaUsage() QuotaUsage { if imageCount == 0 { imageCount = gjson.GetBytes(*e, "data.#").Int() } + + // Claude fallback: input_tokens / output_tokens / cache_read_input_tokens / cache_creation_input_tokens + if prompt == 0 && completion == 0 { + prompt = gjson.GetBytes(*e, "usage.input_tokens").Int() + completion = gjson.GetBytes(*e, "usage.output_tokens").Int() + if cacheRead == 0 { + cacheRead = gjson.GetBytes(*e, "usage.cache_read_input_tokens").Int() + } + if cacheWrite == 0 { + cacheWrite = gjson.GetBytes(*e, "usage.cache_creation_input_tokens").Int() + } + if used == 0 { + used = prompt + completion + } + } + if used > 0 || imageCount > 0 { isguess = false } else { diff --git a/bfe_modules/mod_body_process/llm_util.go b/bfe_modules/mod_body_process/llm_util.go index 86737fb17..aa9cba343 100644 --- a/bfe_modules/mod_body_process/llm_util.go +++ b/bfe_modules/mod_body_process/llm_util.go @@ -134,6 +134,21 @@ func (e *SSEEvent) GetQuotaUsage() QuotaUsage { imageCount = gjson.GetBytes(data, "data.#").Int() } + // Claude fallback: input_tokens / output_tokens / cache_read_input_tokens / cache_creation_input_tokens + if prompt == 0 && completion == 0 { + prompt = gjson.GetBytes(data, "usage.input_tokens").Int() + completion = gjson.GetBytes(data, "usage.output_tokens").Int() + if cacheRead == 0 { + cacheRead = gjson.GetBytes(data, "usage.cache_read_input_tokens").Int() + } + if cacheWrite == 0 { + cacheWrite = gjson.GetBytes(data, "usage.cache_creation_input_tokens").Int() + } + if used == 0 { + used = prompt + completion + } + } + curtoken := int64(0) isguess := true if used > 0 || imageCount > 0 { diff --git a/bfe_server/reverseproxy.go b/bfe_server/reverseproxy.go index 11e64ce6b..f0f488557 100644 --- a/bfe_server/reverseproxy.go +++ b/bfe_server/reverseproxy.go @@ -23,6 +23,7 @@ package bfe_server import ( "crypto/tls" "errors" + "fmt" "io" "math/rand" "net" @@ -1451,6 +1452,22 @@ func (p *ReverseProxy) doSingleAIForward(srv *BfeServer, cluster *bfe_cluster.Bf req := basicReq.HttpRequest + // Detect request protocol/auth style if not already identified. + if aiMeta.AuthStyle == bfe_basic.AuthStyleUnknown || aiMeta.AuthStyle == "" { + aiMeta.AuthStyle = bfe_basic.DetectAuthStyle(basicReq) + } + + // Reject if the cluster provider does not support the request protocol. + if cluster.AIConf != nil && !clusterSupportsAuthStyle(cluster.AIConf.ModelProtocols, aiMeta.AuthStyle) { + err := bfe_basic.NewAiError( + bfe_basic.CodeProviderProtocolMismatch, + bfe_basic.TypeInvalidRequestError, + fmt.Sprintf("request protocol %s not supported by cluster provider (model_protocols=%v)", + aiMeta.AuthStyle, cluster.AIConf.ModelProtocols), + ) + return err.CreateErrorResponse(basicReq), closeAfterReply, nil + } + // prepare out request to downstream RS backend outreq := new(bfe_http.Request) *outreq = *req // includes shallow copies of maps, but okay @@ -1522,7 +1539,14 @@ func (p *ReverseProxy) doSingleAIForward(srv *BfeServer, cluster *bfe_cluster.Bf } aiMeta.AppendClusterKeyName(cluster.Name, selectedKey.Name) if selectedKey.Key != "" { - mod_ai_token_auth.SetApiKey(outreq, selectedKey.Key) + mod_ai_token_auth.SetApiKey(outreq, selectedKey.Key, aiMeta.AuthStyle) + } + } + + // Inject anthropic-version for Anthropic style requests if not present. + if aiMeta.AuthStyle == bfe_basic.AuthStyleAnthropic { + if outreq.Header.Get("anthropic-version") == "" { + outreq.Header.Set("anthropic-version", "2023-06-01") } } @@ -1875,3 +1899,18 @@ func defaultAIKeyPolicy() cluster_conf.AIKeyPolicy { RetryBackoffMax: 5000, } } + +// clusterSupportsAuthStyle checks whether the cluster provider supports the +// request protocol/auth style. An empty modelProtocols defaults to OpenAI +// only for backward compatibility. +func clusterSupportsAuthStyle(modelProtocols []string, authStyle string) bool { + if len(modelProtocols) == 0 { + return authStyle == bfe_basic.AuthStyleOpenAI + } + for _, mp := range modelProtocols { + if mp == authStyle { + return true + } + } + return false +} diff --git a/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md b/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md index dffd3c755..e7375dc62 100644 --- a/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md +++ b/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md @@ -98,6 +98,7 @@ cluster_conf.data为集群转发配置文件。 | AIConf.ModelMapping | Map[string]string | 原请求model -> 后端服务的model 的映射关系 | N | 访问后端服务时将根据请求的 model 字段查找此映射关系,命中则重写请求的 model 字段 | 键值均非空 | | AIConf.MatchPrefix | String | 需要匹配的 provider/model 前缀 | N | 例如 `openrouter/`;必须以 `/` 结尾;用于 OpenRouter 等聚合 provider 场景 | `StripPrefix=true` 时必填 | | AIConf.StripPrefix | Boolean | 是否裁剪 `MatchPrefix` 指定前缀 | N | `true` 时转发给下游前会从请求 model 字段中去掉该前缀;`false` 时仅用于路由标识,不裁剪 | 默认 `false` | +| AIConf.ModelProtocols | []String | 该集群 provider 支持的模型访问协议列表 | N | 例如 `["openai"]`、`["anthropic"]`、`["openai", "anthropic"]`;为空时默认仅支持 `openai` | 元素取值须为 `openai` 或 `anthropic` | | AIConf.ModelTable | Object | 该集群的模型定价表 | N | 由 ai-gateway-api 根据 `Provider` 查询 model_prices 自动填充;当前货币固定为 RMB | 元素见下表「AIConf.ModelTable 元素」 | ##### AIConf.Keys 元素 @@ -300,6 +301,7 @@ cluster_conf.data为集群转发配置文件。 "Provider": "deepseek", "MatchPrefix": "openrouter/", "StripPrefix": true, + "ModelProtocols": ["openai"], "Keys": [ { "Name": "key-primary", @@ -343,6 +345,83 @@ cluster_conf.data为集群转发配置文件。 } ] } + }, + "ai_cluster_anthropic_example": { + "BackendConf": { + "Protocol": "https", + "TimeoutConnSrv": 2000, + "TimeoutResponseHeader": 50000, + "MaxIdleConnsPerHost": 0, + "RetryLevel": 0 + }, + "CheckConf": { + "Schem": "https", + "Uri": "/healthcheck", + "Host": "example.org", + "StatusCode": 200, + "FailNum": 10, + "CheckInterval": 1000 + }, + "GslbBasic": { + "CrossRetry": 0, + "RetryMax": 2, + "HashConf": { + "HashStrategy": 0, + "HashHeader": "Cookie:UID", + "SessionSticky": false + } + }, + "ClusterBasic": { + "TimeoutReadClient": 30000, + "TimeoutWriteClient": 60000, + "TimeoutReadClientAgain": 60000, + "ReqWriteBufferSize": 512, + "ReqFlushInterval": 0, + "ResFlushInterval": -1, + "CancelOnClientClose": false + }, + "AIConf": { + "Type": 0, + "Provider": "my-anthropic", + "ModelProtocols": ["anthropic"], + "Keys": [ + { + "Name": "key-primary", + "Key": "sk-ant-api03-example", + "Weight": 100 + } + ], + "KeyPolicy": { + "Strategy": "weighted_random", + "MaxRetries": 3, + "RetryBackoffInitial": 500, + "RetryBackoffMax": 5000 + }, + "ModelTable": { + "Currency": "RMB", + "Models": [ + { + "Provider": "my-anthropic", + "Model": "claude-3-5-sonnet", + "BaseModel": "claude-3-5-sonnet", + "Mode": "chat", + "Capabilities": ["chat", "tools"], + "SupportedParameters": ["temperature", "max_tokens"], + "Limits": { + "context_window": 200000, + "max_input_tokens": 200000, + "max_output_tokens": 8192 + }, + "Prices": { + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 0.000000375, + "cache_creation_input_token_cost": 0.00000375 + } + } + ] + } + } } } } diff --git a/docs/zh_cn/modifications/2026-08-20-claude-protocol-support/design-changes.md b/docs/zh_cn/modifications/2026-08-20-claude-protocol-support/design-changes.md new file mode 100644 index 000000000..7906cacde --- /dev/null +++ b/docs/zh_cn/modifications/2026-08-20-claude-protocol-support/design-changes.md @@ -0,0 +1,443 @@ +# BFE 支持 Claude 协议转发设计变更 + +## 1. 背景 + +当前 BFE AI 网关的数据面实现默认按 **OpenAI 兼容协议** 处理请求: + +- 客户端 API Key 只从 `Authorization: Bearer ` 提取; +- 转发给上游时无条件注入 `Authorization: Bearer `; +- `usage` 只解析 `prompt_tokens` / `completion_tokens` / `total_tokens`; +- 没有“协议/认证风格”概念。 + +Anthropic Claude Messages API 与 OpenAI 存在几个转发视角的关键差异: + +| 项目 | OpenAI / 兼容协议 | Claude Messages API | +|------|-------------------|---------------------| +| 对话端点 | `POST /v1/chat/completions` | `POST /v1/messages` | +| 客户端认证头 | `Authorization: Bearer ` | `x-api-key: ` | +| 版本头 | 无 | 必须 `anthropic-version: 2023-06-01` | +| 响应 usage | `usage.prompt_tokens` / `completion_tokens` / `total_tokens` | `usage.input_tokens` / `output_tokens`(无 total) | + +BFE 对 Claude 协议**不需要做协议翻译**(`content[]` 与 `choices[]` 的结构差异对网关透明),但必须解决三个核心差异: + +1. 认证头差异; +2. `anthropic-version` 版本头要求; +3. Usage 字段差异。 + +本设计变更仅涉及 **BFE 数据面**;控制面(`ai-gateway-api`)的配套修改在本仓库外完成。 + +--- + +## 2. 目标 + +1. 让 BFE 在只做转发的前提下,支持把客户端 Claude Messages API 请求原样转发到 Anthropic 后端。 +2. 自动识别 Claude 协议风格(路径 + 请求头)。 +3. 按协议风格向上游注入正确的 API Key(`x-api-key`)和 `anthropic-version`。 +4. 扩展 usage 解析,支持 `input_tokens` / `output_tokens`,保证配额扣减、成本计算与访问日志正确。 +5. 校验请求协议与集群 provider 支持的协议是否匹配,不匹配直接拒绝。 +6. 保持 OpenAI / DeepSeek 等现有集群的向后兼容。 + +--- + +## 3. 变更总览 + +| 层级 | 变更点 | 影响文件 | +|---|---|---| +| 请求基础信息 | `AiBasicInfo` 新增 `AuthStyle`,`GetApiKey` 支持 `x-api-key` fallback | `bfe/bfe_basic/request_ai_basic.go` | +| 认证头注入 | `SetApiKey` 按 `AuthStyle` 注入 `Authorization` 或 `x-api-key` | `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` | +| 版本头注入 | `doSingleAIForward` 在 Anthropic 风格下自动补 `anthropic-version` | `bfe/bfe_server/reverseproxy.go` | +| Usage 解析 | `UpdateCtxByUsage`、`SSEEvent.GetQuotaUsage`、`RawEvent.GetQuotaUsage` 支持 Claude usage 字段 | `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go`、`bfe/bfe_modules/mod_body_process/llm_util.go`、`bfe/bfe_modules/mod_body_process/body_process.go` | +| 协议风格匹配 | `doSingleAIForward` 比较请求 `AuthStyle` 与 `AIConf.ModelProtocols`,不匹配返回 400 | `bfe/bfe_server/reverseproxy.go` | +| 配置扩展(阶段一) | `AIConf` 新增 `ModelProtocols`,接收控制面下发的 provider 协议列表 | `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` | +| 访问日志 | 新增 `ai_protocol` 字段,记录 `AuthStyle` | `bfe-access-pb/bfe_access_pb/bfe_access.proto`、`bfe/bfe_modules/mod_access_pb3/request_log.go` | + + +--- + +## 4. 详细设计 + +### 4.1 协议风格定义 + +```go +const ( + AuthStyleOpenAI = "openai" + AuthStyleAnthropic = "anthropic" + AuthStyleUnknown = "unknown" +) +``` + +识别规则( purely based on 请求特征,由 BFE 底层代码完成,不依赖用户路由表条件): + +1. 请求路径:`strings.HasPrefix(req.HttpRequest.URL.Path, "/v1/messages")` → `anthropic`; +2. 请求头:存在 `x-api-key` 且不存在 `Authorization` → `anthropic`; +3. 否则默认 `openai`。 + +识别结果写入 `AiBasicInfo.AuthStyle`。 + +> **命名约定**:`anthropic` 是协议/风格标识符(与 `model_protocols` 枚举一致),`Claude` 是 Anthropic 的模型/助手品牌。BFE 代码中统一用 `anthropic` 表示该协议风格。 +> +> 注意:不能靠 `model` 名字判断,也不能靠 `AIConf.Provider` 名称判断(provider 名称是用户自定义的,如 `my-anthropic`)。协议匹配校验使用 `AIConf.ModelProtocols`。 + +### 4.2 扩展 `AiBasicInfo` + +文件:`bfe/bfe_basic/request_ai_basic.go` + +```go +type AiBasicInfo struct { + // ... 已有字段 ... + AuthStyle string // openai / anthropic / unknown + // ... +} +``` + +`GetApiKey` 改造为: + +```go +func GetApiKey(req *Request) string { + // 1. 优先读取 Authorization: Bearer + authHeader := req.HttpRequest.Header.Get("Authorization") + if authHeader != "" { + authHeader = strings.TrimPrefix(authHeader, "Bearer ") + authHeader = strings.TrimPrefix(authHeader, "sk-") + if ai := req.GetAiBasicInfo(); ai != nil { + ai.AuthStyle = AuthStyleOpenAI + } + return authHeader + } + + // 2. fallback 读取 x-api-key + if xApiKey := req.HttpRequest.Header.Get("x-api-key"); xApiKey != "" { + if ai := req.GetAiBasicInfo(); ai != nil { + ai.AuthStyle = AuthStyleAnthropic + } + return xApiKey + } + + return "" +} +``` + +优先级说明:当客户端同时传 `Authorization` 和 `x-api-key` 时,优先 `Authorization`,保证现有 OpenAI 客户端行为不变。 + +### 4.3 按风格注入 API Key + +文件:`bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` + +当前 `SetApiKey`: + +```go +func SetApiKey(req *bfe_http.Request, apiKey string) { + if apiKey == "" { + return + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) +} +``` + +改造后: + +```go +func SetApiKey(req *bfe_http.Request, apiKey string, authStyle string) { + if apiKey == "" { + return + } + + switch authStyle { + case AuthStyleAnthropic: + req.Header.Set("x-api-key", apiKey) + default: + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) + } +} +``` + +调用点 `bfe/bfe_server/reverseproxy.go:1525` 同步更新为: + +```go +mod_ai_token_auth.SetApiKey(outreq, selectedKey.Key, aiMeta.AuthStyle) +``` + +### 4.4 注入 `anthropic-version` + +文件:`bfe/bfe_server/reverseproxy.go`(`doSingleAIForward` 中设置 API Key 之后、`clusterInvoke` 之前) + +```go +if aiMeta.AuthStyle == AuthStyleAnthropic { + if outreq.Header.Get("anthropic-version") == "" { + outreq.Header.Set("anthropic-version", "2023-06-01") + } +} +``` + +阶段一选择 Anthropic 当前长期支持的版本 `2023-06-01` 作为默认值,由 BFE 自动注入。阶段一不引入额外配置字段。 + +### 4.5 Usage 解析扩展 + +Claude 响应 usage 示例: + +```json +{ + "usage": { + "input_tokens": 1000, + "output_tokens": 500, + "cache_creation_input_tokens": 200, + "cache_read_input_tokens": 300 + } +} +``` + +#### 4.5.1 非流式:`mod_ai_token_auth.go` + +`UpdateCtxByUsage` 在读取 OpenAI 字段后增加 fallback: + +```go +used = gjson.GetBytes(data, "usage.total_tokens").Int() +prompt = gjson.GetBytes(data, "usage.prompt_tokens").Int() +completion = gjson.GetBytes(data, "usage.completion_tokens").Int() +cacheRead = gjson.GetBytes(data, "usage.cache_read_tokens").Int() +cacheWrite = gjson.GetBytes(data, "usage.cache_write_tokens").Int() + +// Claude fallback +if prompt == 0 && completion == 0 { + prompt = gjson.GetBytes(data, "usage.input_tokens").Int() + completion = gjson.GetBytes(data, "usage.output_tokens").Int() + if cacheRead == 0 { + cacheRead = gjson.GetBytes(data, "usage.cache_read_input_tokens").Int() + } + if cacheWrite == 0 { + cacheWrite = gjson.GetBytes(data, "usage.cache_creation_input_tokens").Int() + } + if used == 0 { + used = prompt + completion + } +} +``` + +#### 4.5.2 流式:`llm_util.go` / `body_process.go` + +`SSEEvent.GetQuotaUsage` 与 `RawEvent.GetQuotaUsage` 做同样扩展: + +```go +used := gjson.GetBytes(data, "usage.total_tokens").Int() +prompt := gjson.GetBytes(data, "usage.prompt_tokens").Int() +completion := gjson.GetBytes(data, "usage.completion_tokens").Int() +cacheRead := gjson.GetBytes(data, "usage.cache_read_tokens").Int() +cacheWrite := gjson.GetBytes(data, "usage.cache_write_tokens").Int() + +// Claude fallback +if prompt == 0 && completion == 0 { + prompt = gjson.GetBytes(data, "usage.input_tokens").Int() + completion = gjson.GetBytes(data, "usage.output_tokens").Int() + if cacheRead == 0 { + cacheRead = gjson.GetBytes(data, "usage.cache_read_input_tokens").Int() + } + if cacheWrite == 0 { + cacheWrite = gjson.GetBytes(data, "usage.cache_creation_input_tokens").Int() + } + if used == 0 { + used = prompt + completion + } +} +``` + +> 说明: +> - Anthropic 的 `input_tokens` 已经包含 cache read / cache creation 部分,因此 `UsedQuota` 仍由 `input_tokens + output_tokens` 推导; +> - `cache_read_input_tokens` / `cache_creation_input_tokens` 单独写入 `CacheReadTokens` / `CacheWriteTokens`,供后续按 cache 价格项计费; +> - Anthropic 目前没有 `audio_*` / `image_count` 字段,本期保持现有解析逻辑即可。 + +最终 `TokenUsage.PromptTokens` / `CompletionTokens` / `UsedQuota` 与 OpenAI 场景保持一致,配额、成本、日志字段无需额外改造。 + +### 4.6 协议风格匹配与拒绝 + +文件:`bfe/bfe_server/reverseproxy.go`(`doSingleAIForward` 开头,在构建 `outreq` 之前) + +```go +// 若此前未识别风格,则兜底识别 +if aiMeta.AuthStyle == AuthStyleUnknown || aiMeta.AuthStyle == "" { + aiMeta.AuthStyle = DetectAuthStyle(basicReq) +} + +// 判断当前集群是否支持该请求协议 +if !clusterSupportsAuthStyle(cluster.AIConf.ModelProtocols, aiMeta.AuthStyle) { + err := bfe_basic.NewAiError( + bfe_basic.CodeProviderProtocolMismatch, + bfe_basic.TypeInvalidRequestError, + fmt.Sprintf("request protocol %s not supported by cluster provider (model_protocols=%v)", + aiMeta.AuthStyle, cluster.AIConf.ModelProtocols), + ) + return err.CreateErrorResponse(basicReq), closeAfterReply, nil +} +``` + +其中 `clusterSupportsAuthStyle` 直接判断 `authStyle` 是否在 `model_protocols` 中(因为 `AuthStyle` 取值与 `model_protocols` 枚举保持一致): + +```go +func clusterSupportsAuthStyle(modelProtocols []string, authStyle string) bool { + if len(modelProtocols) == 0 { + return authStyle == AuthStyleOpenAI // 兼容旧配置 + } + for _, mp := range modelProtocols { + if mp == authStyle { + return true + } + } + return false +} +``` + +> 当前 `AIConf` 需要新增 `ModelProtocols []string`;若为空则默认只支持 OpenAI,保证旧配置向后兼容。 + +### 4.7 `AIConf` 新增 `ModelProtocols []string` 字段 + +文件:`bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` + +```go +type AIConf struct { + Type int + ModelMapping *map[string]string + Provider string + Keys []AIKey + KeyPolicy *AIKeyPolicy + ModelTable *ModelTable + MatchPrefix string + StripPrefix bool + + // ModelProtocols 表示该集群所属 provider 支持的模型访问协议。 + // 来源:ai-gateway-api 的 provider.model_protocols,如 ["openai"]、["anthropic"]、["openai", "anthropic"]。 + // 用于 doSingleAIForward 中判断请求协议风格是否被当前集群支持。 + ModelProtocols []string +} +``` + +控制面(`ai-gateway-api`)的 `model/icluster_conf/cluster.go` 在 `newAIConf` 时需要把 provider 的 `model_protocols` 透传进来: + +```go +func newAIConf(llmConfig *LLMConfig, modelTable *cluster_conf.ModelTable, + providerKeys []iprovider.ProviderKey, + providerModelProtocols []string) *cluster_conf.AIConf { + aiConf := &cluster_conf.AIConf{ + Type: 0, + ModelMapping: convertToBFEModelMapping(llmConfig.ModelMappings), + Keys: []cluster_conf.AIKey{}, + ModelProtocols: providerModelProtocols, + } + // ... 其余字段不变 ... + return aiConf +} +``` + +> 为什么不能通过 `AIConf.Provider` 名称推断协议?cluster/provider 分离后,`provider` 名称是用户自定义的(如 `my-anthropic`),不再限定为系统内置枚举,只有显式的 `ModelProtocols` 才能准确表达该 provider 支持哪些协议。 + +### 4.8 访问日志增加 `ai_protocol` 字段 + +文件:`bfe-access-pb/bfe_access_pb/bfe_access.proto` + +在 `ai_mode`(`716`)之后新增字段: + +```proto +// AI protocol style, e.g. openai, anthropic +optional string ai_protocol = 717; +``` + +修改后需要重新生成 `bfe_access.pb.go`,并同步更新 `bfe-access-pb/docs/protobuf.md`。 + +文件:`bfe/bfe_modules/mod_access_pb3/request_log.go` + +在填充访问日志时,把 `AiBasicInfo.AuthStyle` 写入 `ai_protocol`: + +```go +if aiInfo := req.GetAiBasicInfo(); aiInfo != nil { + // ... 已有字段 ... + if aiInfo.Provider != "" { + reqLog.AiProvider = proto.String(aiInfo.Provider) + } + if aiInfo.AuthStyle != "" { + reqLog.AiProtocol = proto.String(aiInfo.AuthStyle) + } + // ... +} +``` + +> 说明:`ai_provider` 保存用户自定义的 provider 名称,无法准确反映协议类型;新增 `ai_protocol` 后,一个标识“哪个 provider”,一个标识“什么协议”。 + +--- + +## 5. 关键代码索引 + +| 文件 | 行号范围 | 说明 | +|---|---|---| +| `bfe/bfe_basic/request_ai_basic.go` | 82-98 | `AiBasicInfo` 结构 | +| `bfe/bfe_basic/request_ai_basic.go` | 118-130 | `GetApiKey` | +| `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` | 114-153 | `UpdateCtxByUsage` | +| `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` | 233-239 | `SetApiKey` | +| `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` | 241-253 | `GetApiKey`(模块内辅助函数) | +| `bfe/bfe_server/reverseproxy.go` | 1515-1527 | `doSingleAIForward` 中应用 `AIConf`、注入 API Key | +| `bfe/bfe_modules/mod_body_process/llm_util.go` | 123-157 | `SSEEvent.GetQuotaUsage` | +| `bfe/bfe_modules/mod_body_process/body_process.go` | 422-455 | `RawEvent.GetQuotaUsage` | +| `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` | 167-181 | `AIConf` 结构 | +| `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` | 167-181 | `AIConf.ModelProtocols` 新增字段 | +| `bfe-access-pb/bfe_access_pb/bfe_access.proto` | 357-365 | `ai_protocol = 717` 新增字段 | +| `bfe/bfe_modules/mod_access_pb3/request_log.go` | 400-410 | 将 `AiBasicInfo.AuthStyle` 写入 `ai_protocol` | + +--- + +## 6. 测试计划 + +### 6.1 单元测试 + +| 被测函数 | 场景 | +|---|---| +| `bfe_basic.GetApiKey` | `Authorization: Bearer xxx`;`x-api-key: xxx`;两者同时存在时的优先级;无 key | +| `mod_ai_token_auth.SetApiKey` | `openai` 风格生成 `Authorization: Bearer`;`anthropic` 风格生成 `x-api-key` | +| `mod_ai_token_auth.UpdateCtxByUsage` | OpenAI usage JSON;Claude usage JSON;混合字段时的优先级 | +| `mod_body_process.SSEEvent.GetQuotaUsage` | Claude 流式 usage 字段解析 | +| `mod_body_process.RawEvent.GetQuotaUsage` | Claude 非流式 usage 字段解析 | +| `bfe_basic.DetectAuthStyle` | `/v1/messages`、`x-api-key`、OpenAI 默认路径识别 | +| `bfe_server`(协议匹配) | Anthropic 风格请求命中 `model_protocols=["openai"]` 的集群返回 400;OpenAI 风格请求命中 `model_protocols=["anthropic"]` 的集群返回 400 | +| `mod_access_pb3` | `ai_protocol` 字段被正确填充为 `openai` 或 `anthropic` | + +### 6.2 集成测试 + +| 用例编号 | 场景 | 预期 | +|---|---|---| +| SC02-TC-XX | 客户端 `POST /v1/messages`,带 `x-api-key` | upstream 收到 `x-api-key` 为选中的 cluster key,`anthropic-version` 被自动注入 | +| SC02-TC-XX | Claude 非流式响应返回 `input_tokens`/`output_tokens` | 访问日志 `ai_input_tokens`/`ai_output_tokens`/`ai_total_tokens` 正确;访问日志 `ai_protocol` 为 `anthropic` | +| SC02-TC-XX | Claude 流式响应返回 usage | RMB 配额扣减与访问日志正确;访问日志 `ai_protocol` 为 `anthropic` | +| SC02-TC-XX | OpenAI 风格请求 | 访问日志 `ai_protocol` 为 `openai` | +| SC02-TC-XX | Anthropic 风格请求命中只支持 OpenAI 的集群 | BFE 返回 400 `PROVIDER_PROTOCOL_MISMATCH`,upstream 不被调用 | +| SC02-TC-XX | OpenAI 风格请求命中只支持 Claude 的集群 | BFE 返回 400 `PROVIDER_PROTOCOL_MISMATCH`,upstream 不被调用 | +| SC02-TC-XX | 同集群同时命中 OpenAI 与 Claude 路径 | 不同认证头不互相污染 | + +### 6.3 回归测试 + +- `go test ./bfe_basic/...` +- `go test ./bfe_modules/mod_ai_token_auth/...` +- `go test ./bfe_modules/mod_body_process/...` +- `go test ./bfe_server/...` +- `go test ./bfe_modules/mod_access_pb3/...` + +--- + +## 7. 风险与回滚 + +| 风险 | 缓解措施 | +|---|---| +| 认证头冲突 | 明确优先级:`Authorization` > `x-api-key`,兼容现有 OpenAI 客户端 | +| `anthropic-version` 默认值过期 | 选择长期支持版本 `2023-06-01`;阶段一不引入额外配置字段 | +| 聚合平台模型名含 `anthropic/` 或 `claude` 但走 OpenAI 路径 | 不依赖 model 名判断,依赖路径/头/集群配置 | +| Claude 无 `total_tokens` | `UsedQuota` 由 `input + output` 推导 | +| 影响现有 OpenAI/DeepSeek 集群 | 默认 `AuthStyle == openai`,仅在明确识别为 Claude 时切换;`AIConf.ModelProtocols` 为空时默认只支持 OpenAI | +| 用 provider 名称硬编码协议风格 | provider 名称由用户自定义,不能再用 `"anthropic"` 判断;必须通过 `AIConf.ModelProtocols` 校验 | + +**回滚**:该设计修改的是 BFE 二进制行为。若线上需要恢复旧行为,需回滚代码并重新发版;影响面仅涉及 AI 网关路径。 + +--- + +## 8. 与控制面的边界 + +控制面(`ai-gateway-api`)需要完成的配套工作(不在本仓库实现): + +1. `/providers` 创建/更新时设置 `model_protocols` 包含 `anthropic`(首期枚举已支持); +2. `/clusters` 的 `llm_config.provider` 引用该 provider(`provider_type` 字段已移除); +3. `model/icluster_conf/cluster.go` 的 `newAIConf` 把 provider 的 `model_protocols` 写入 BFE `AIConf.ModelProtocols`; +BFE 数据面按“阶段一把 `model_protocols` 透传到 `AIConf` 做校验”推进,先以最小改动完成 Claude 转发支持,同时为后续多协议扩展预留空间。 diff --git a/docs/zh_cn/sys_design/ai_access_log_fields.md b/docs/zh_cn/sys_design/ai_access_log_fields.md index b587a0a60..41657d9b0 100644 --- a/docs/zh_cn/sys_design/ai_access_log_fields.md +++ b/docs/zh_cn/sys_design/ai_access_log_fields.md @@ -19,7 +19,7 @@ BFE 作为 AI 网关,需要把请求在认证、路由、转发、计费等各 ## 2. 字段总览 -AI 可观测字段统一占用 `bfe-access-pb` 的 701-900 编号区间,当前已定义 26 个字段: +AI 可观测字段统一占用 `bfe-access-pb` 的 701-900 编号区间,当前已定义 27 个字段: | 编号 | 字段名 | 类型 | 说明 | 采集模块 | |------|--------|------|------|----------| @@ -39,6 +39,7 @@ AI 可观测字段统一占用 `bfe-access-pb` 的 701-900 编号区间,当前 | 714 | `ai_provider` | `string` | 上游模型提供商标识 | `bfe_server/reverseproxy.go` | | 715 | `ai_retry_count` | `uint32` | 模型调用层 key-level 重试次数 | `bfe_server/reverseproxy.go` | | 716 | `ai_mode` | `string` | AI 请求模式,如 `chat`、`image_generation` | `bfe_server/http_conn.go` | +| 717 | `ai_protocol` | `string` | AI 协议/认证风格,如 `openai`、`anthropic` | `bfe_basic.GetApiKey` / `bfe_server/reverseproxy.go` | | 761 | `ai_cost_value` | `int64` | 估算成本(定点整数,精度由币种决定) | `mod_ai_token_auth` | | 762 | `ai_cost_currency` | `string` | 成本币种,如 `RMB` / `USD` | `bfe_server/reverseproxy.go` | | 781 | `ai_cache_read_tokens` | `int64` | 从 cache 读取的 Token 数(已包含在 `ai_input_tokens` 中) | `mod_ai_token_auth` / `mod_body_process` | @@ -55,7 +56,7 @@ AI 可观测字段统一占用 `bfe-access-pb` 的 701-900 编号区间,当前 | 编号区间 | 用途 | |----------|------| | 701 - 713 | 已投入使用字段,保持现状,不再调整 | -| 714 - 760 | 模型与请求基础信息(model、provider、stream、retry、cache 等) | +| 714 - 760 | 模型与请求基础信息(model、provider、protocol、stream、retry、cache 等) | | 761 - 800 | Token 与成本计量(761-780 为普通 token/cost;781-790 为 cache/audio/image 子项) | | 801 - 840 | 路由、转换与插件 | | 841 - 880 | 安全、合规与隐私 | @@ -76,6 +77,7 @@ AI 可观测字段统一占用 `bfe-access-pb` 的 701-900 编号区间,当前 │ bfe_server/http_conn.go │ │ - 初始化 AiBasicInfo │ │ - 提取 ai_apikey_id(原始 key,后续会被 key_id 覆盖) │ +│ - 根据 Authorization / x-api-key / 路径识别 AuthStyle │ │ - 提取 ai_requested_model │ └──────────────────────────┬──────────────────────────────────┘ ▼ @@ -90,8 +92,10 @@ AI 可观测字段统一占用 `bfe-access-pb` 的 701-900 编号区间,当前 ┌─────────────────────────────────────────────────────────────┐ │ bfe_server/reverseproxy.go │ │ - ServeHTTPForAI / aiClusterInvoke / doSingleAIForward │ -│ - 设置 ai_provider、ai_retry_count、 │ +│ - 检测/兜底 AuthStyle,校验 cluster.AIConf.ModelProtocols │ +│ - 设置 ai_provider、ai_retry_count、ai_protocol、 │ │ │ ai_cluster_key_names、ai_target_model │ +│ - 按 AuthStyle 注入 API Key 与 anthropic-version │ │ - 触发 cluster-level fallback │ └──────────────────────────┬──────────────────────────────────┘ ▼ @@ -125,6 +129,7 @@ type AiBasicInfo struct { TargetModel string // 704 ai_target_model Mode string // 716 ai_mode Provider string // 714 ai_provider + AuthStyle string // 717 ai_protocol (openai / anthropic / unknown) RetryCount uint32 // 715 ai_retry_count CostCurrency string // 762 ai_cost_currency tokenUsage TokenUsage @@ -220,9 +225,13 @@ message AIRouteRuleHit { 在 `doSingleAIForward()` 中: +- 若 `AiBasicInfo.AuthStyle` 尚未识别,调用 `bfe_basic.DetectAuthStyle()` 兜底识别; +- 比较请求 `AuthStyle` 与 `cluster.AIConf.ModelProtocols`,不匹配时返回 400; - 从 `cluster.AIConf.Provider` 写入 `AiBasicInfo.Provider`; - 从 `cluster.AIConf.ModelTable.Currency` 写入 `AiBasicInfo.CostCurrency`; - 调用 `AiBasicInfo.AppendClusterKeyName(cluster.Name, selectedKey.Name)` 记录尝试; +- 按 `AuthStyle` 调用 `mod_ai_token_auth.SetApiKey()` 注入 `Authorization: Bearer` 或 `x-api-key`; +- Anthropic 风格下自动补 `anthropic-version: 2023-06-01`; - 在 `ModelMapping`、路由 target/fallback model override、provider prefix strip 后更新 `TargetModel`。 在 `aiClusterInvoke()` 的 key-level retry 循环中: @@ -240,7 +249,7 @@ message AIRouteRuleHit { `reqAiInfoGen()` 负责把上述所有字段从 `AiBasicInfo`、`AiRateLimitHitInfo`、`AiRouteResult` 映射到 `RequestLog`: - 字段重命名:`AiApikey`→`AiApikeyId`、`AiMappedModel`→`AiTargetModel`、`AiPromptTokens`→`AiInputTokens`; -- 新增字段:`AiMode`、`AiProvider`、`AiRetryCount`、`AiCostValue`、`AiCostCurrency`、`AiCacheReadTokens`、`AiCacheWriteTokens`、`AiAudioInputTokens`、`AiAudioOutputTokens`、`AiImageCount`、`AiRouteRuleHits`、`AiClusterKeyNames`、`AiAuthHitQuotaPlans`。 +- 新增字段:`AiMode`、`AiProtocol`、`AiProvider`、`AiRetryCount`、`AiCostValue`、`AiCostCurrency`、`AiCacheReadTokens`、`AiCacheWriteTokens`、`AiAudioInputTokens`、`AiAudioOutputTokens`、`AiImageCount`、`AiRouteRuleHits`、`AiClusterKeyNames`、`AiAuthHitQuotaPlans`。 --- @@ -255,7 +264,7 @@ message AIRouteRuleHit { ## 7. 测试与验证 1. **单元测试**:`bfe_modules/mod_access_pb3/request_log_test.go` 覆盖所有字段的赋值逻辑; -2. **集成测试**:`tests/integration/implementation/scenario-SC05-access-log-ai-fields/` 启动真实 BFE 进程,发送 AI 请求后解码 b2log,校验全部 26 个字段(包括 `ai_mode`、`ai_image_count` 等图像生成场景字段)。 +2. **集成测试**:`tests/integration/implementation/scenario-SC05-access-log-ai-fields/` 启动真实 BFE 进程,发送 AI 请求后解码 b2log,校验全部 27 个字段(包括 `ai_mode`、`ai_protocol`、`ai_image_count` 等图像生成场景字段)。 --- @@ -263,7 +272,9 @@ message AIRouteRuleHit { - `bfe-access-pb/docs/protobuf.md` - `bfe-access-pb/RELEASE_NOTES_v0.3.3.md` +- `bfe-access-pb/RELEASE_NOTES_v0.3.4.md` - `bfe/docs/zh_cn/modifications/2026-08-19-update-ai-access-log-fields/design-changes.md` - `bfe/docs/zh_cn/modifications/2026-08-22-audio-token-billing-support/design-changes.md` - `bfe/docs/zh_cn/modifications/2026-08-22-image-generation-billing-support/design-changes.md` +- `bfe/docs/zh_cn/modifications/2026-08-20-claude-protocol-support/design-changes.md` - `bfe/tests/integration/测试设计文档/scenario-SC05-AI访问日志字段校验/场景说明.md` diff --git a/docs/zh_cn/sys_design/claude_protocol_support.md b/docs/zh_cn/sys_design/claude_protocol_support.md new file mode 100644 index 000000000..fccd946d9 --- /dev/null +++ b/docs/zh_cn/sys_design/claude_protocol_support.md @@ -0,0 +1,353 @@ +# BFE Claude 协议支持 + +## 1. 背景与目标 + +### 1.1 背景 + +BFE AI 网关早期默认按 **OpenAI 兼容协议** 处理请求: + +- 客户端 API Key 只从 `Authorization: Bearer ` 提取; +- 转发给上游时统一注入 `Authorization: Bearer `; +- Usage 只解析 `prompt_tokens` / `completion_tokens` / `total_tokens`; +- 没有"协议/认证风格"概念。 + +Anthropic Claude Messages API 与 OpenAI 在网关转发视角存在关键差异: + +| 项目 | OpenAI / 兼容协议 | Claude Messages API | +|------|-------------------|---------------------| +| 对话端点 | `POST /v1/chat/completions` | `POST /v1/messages` | +| 客户端认证头 | `Authorization: Bearer ` | `x-api-key: ` | +| 版本头 | 无 | 必须 `anthropic-version: 2023-06-01` | +| 响应 usage | `usage.prompt_tokens` / `completion_tokens` / `total_tokens` | `usage.input_tokens` / `output_tokens`(无 total) | + +BFE 对 Claude 协议**不做协议翻译**(`content[]` 与 `choices[]` 的结构差异对网关透明),但必须解决三个核心差异: + +1. 认证头差异; +2. `anthropic-version` 版本头要求; +3. Usage 字段差异。 + +### 1.2 目标 + +1. 让 BFE 在只做转发的前提下,支持把客户端 Claude Messages API 请求原样转发到 Anthropic 后端; +2. 自动识别 Claude 协议风格(路径 + 请求头),并写入访问日志 `ai_protocol`; +3. 按协议风格向上游注入正确的 API Key(`x-api-key`)和 `anthropic-version`; +4. 扩展 usage 解析,支持 `input_tokens` / `output_tokens`,保证配额扣减、成本计算与访问日志正确; +5. 校验请求协议与集群 provider 支持的协议是否匹配,不匹配直接拒绝; +6. 保持 OpenAI / DeepSeek 等现有集群的向后兼容。 + +--- + +## 2. 协议风格定义 + +```go +const ( + AuthStyleOpenAI = "openai" + AuthStyleAnthropic = "anthropic" + AuthStyleUnknown = "unknown" +) +``` + +识别规则(由 BFE 底层代码完成,不依赖用户路由表条件): + +1. 请求路径:`strings.HasPrefix(req.HttpRequest.URL.Path, "/v1/messages")` → `anthropic`; +2. 请求头:存在 `x-api-key` 且不存在 `Authorization` → `anthropic`; +3. 否则默认 `openai`。 + +识别结果写入 `AiBasicInfo.AuthStyle`。 + +> **命名约定**:`anthropic` 是协议/风格标识符(与 `model_protocols` 枚举一致),`Claude` 是 Anthropic 的模型/助手品牌。BFE 代码中统一用 `anthropic` 表示该协议风格。 +> +> 注意:不能靠 `model` 名字判断,也不能靠 `AIConf.Provider` 名称判断(provider 名称是用户自定义的,如 `my-anthropic`)。协议匹配校验使用 `AIConf.ModelProtocols`。 + +--- + +## 3. 系统架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 客户端请求 │ +└──────────────────────────┬──────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ bfe_server/http_conn.go │ +│ - 初始化 AiBasicInfo │ +│ - 从 Authorization 或 x-api-key 提取 ClientApiKey │ +│ - 识别并写入 AuthStyle(openai / anthropic) │ +└──────────────────────────┬──────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ HandleFoundProduct 回调链 │ +│ ├─ mod_ai_token_auth: Token 鉴权、Quota 校验 │ +│ ├─ mod_ai_route: AI 路由选择 │ +│ └─ mod_ai_rate_limit: 限流判断 │ +└──────────────────────────┬──────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ bfe_server/reverseproxy.go │ +│ doSingleAIForward(): │ +│ - 兜底 DetectAuthStyle() │ +│ - 校验 cluster.AIConf.ModelProtocols │ +│ - 按 AuthStyle 注入 Authorization: Bearer 或 x-api-key │ +│ - Anthropic 风格注入 anthropic-version: 2023-06-01 │ +│ - 模型映射 / 前缀裁剪后转发至后端 │ +└──────────────────────────┬──────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 响应阶段 │ +│ ├─ mod_body_process: 流式 usage 解析(SSE) │ +│ └─ mod_ai_token_auth: 非流式 usage 解析、配额扣减 │ +│ 支持 OpenAI + Claude usage 字段 │ +└──────────────────────────┬──────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ mod_access_pb3 │ +│ - 输出 ai_protocol 等访问日志字段 │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. 核心数据结构 + +### 4.1 `bfe_basic.AiBasicInfo` + +`bfe/bfe_basic/request_ai_basic.go` + +```go +type AiBasicInfo struct { + ClientApiKey string + ClientKeyId string + ClientModel string + TargetModel string + Mode string // request mode, e.g. chat, image_generation + Provider string // upstream model provider, e.g. openai, deepseek + AuthStyle string // request protocol/auth style: openai / anthropic / unknown + RetryCount uint32 + CostCurrency string + tokenUsage TokenUsage + ApikeyTags []ApikeyTag + TokenTimeInfo TokenTimeInfo + AiAuthInfo AiAuthInfo + ClusterKeyNames []ClusterKeyName + + allowEstimateToken bool +} +``` + +### 4.2 `cluster_conf.AIConf` + +`bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` + +```go +type AIConf struct { + Type int + ModelMapping *map[string]string + Provider string + Keys []AIKey + KeyPolicy *AIKeyPolicy + ModelTable *ModelTable + MatchPrefix string + StripPrefix bool + + // 新增:provider 支持的模型访问协议 + ModelProtocols []string +} +``` + +`ModelProtocols` 来源:ai-gateway-api 的 `provider.model_protocols`,如 `["openai"]`、`["anthropic"]`、`["openai", "anthropic"]`。用于 `doSingleAIForward` 中判断请求协议风格是否被当前集群支持。 + +> 为什么不能通过 `AIConf.Provider` 名称推断协议?cluster/provider 分离后,`provider` 名称是用户自定义的(如 `my-anthropic`),不再限定为系统内置枚举,只有显式的 `ModelProtocols` 才能准确表达该 provider 支持哪些协议。 + +--- + +## 5. 模块职责 + +### 5.1 `bfe_server/http_conn.go` + +- 初始化 `AiBasicInfo`; +- 调用 `bfe_basic.GetApiKey()` 提取 API Key: + - 优先 `Authorization: Bearer `,识别为 `AuthStyleOpenAI`; + - fallback `x-api-key: `,识别为 `AuthStyleAnthropic`; +- 提取 `model` 字段,设置 `ClientModel` / `TargetModel`; +- 根据路径推断 `Mode`。 + +### 5.2 `bfe_basic.DetectAuthStyle` + +在 `bfe_basic/request_ai_basic.go` 中实现,作为兜底识别: + +```go +func DetectAuthStyle(req *Request) string { + if strings.HasPrefix(req.HttpRequest.URL.Path, "/v1/messages") { + return AuthStyleAnthropic + } + if req.HttpRequest.Header.Get("x-api-key") != "" && + req.HttpRequest.Header.Get("Authorization") == "" { + return AuthStyleAnthropic + } + return AuthStyleOpenAI +} +``` + +### 5.3 `bfe_server/reverseproxy.go` + +在 `doSingleAIForward()` 中: + +1. 若 `aiMeta.AuthStyle` 为空或 `unknown`,调用 `DetectAuthStyle()` 兜底; +2. 调用 `clusterSupportsAuthStyle(cluster.AIConf.ModelProtocols, aiMeta.AuthStyle)` 校验: + - `ModelProtocols` 为空时默认只支持 `openai`; + - 不匹配时返回 400 `PROVIDER_PROTOCOL_MISMATCH`; +3. 按 `AuthStyle` 调用 `mod_ai_token_auth.SetApiKey()`: + - `openai` → `Authorization: Bearer `; + - `anthropic` → `x-api-key: `; +4. Anthropic 风格下,若请求未带 `anthropic-version`,自动注入 `anthropic-version: 2023-06-01`; +5. 继续执行模型映射、前缀裁剪、cluster 转发。 + +### 5.4 `mod_ai_token_auth` + +- `SetApiKey(req, apiKey, authStyle)` 按协议注入对应认证头; +- `UpdateCtxByUsage()` 在 OpenAI 字段后增加 Claude fallback: + - `usage.input_tokens` → `PromptTokens`; + - `usage.output_tokens` → `CompletionTokens`; + - `usage.cache_read_input_tokens` → `CacheReadTokens`; + - `usage.cache_creation_input_tokens` → `CacheWriteTokens`; + - `UsedQuota` 由 `input + output` 推导。 + +### 5.5 `mod_body_process` + +- `SSEEvent.GetQuotaUsage()` 与 `RawEvent.GetQuotaUsage()` 同样支持 Claude usage 字段 fallback; +- 继续负责流式场景的 `TTFT` / `TPOT` 计算。 + +### 5.6 `mod_access_pb3` + +- `reqAiInfoGen()` 将 `AiBasicInfo.AuthStyle` 写入 `RequestLog.ai_protocol`(字段 717)。 + +--- + +## 6. 配置说明 + +### 6.1 `AIConf.ModelProtocols` + +在 `cluster_conf.data` 的 `AIConf` 中配置: + +```json +{ + "AIConf": { + "Type": 0, + "Provider": "my-anthropic", + "ModelProtocols": ["anthropic"], + "Keys": [ + { + "Name": "key-primary", + "Key": "sk-ant-api03-example", + "Weight": 100 + } + ], + "ModelTable": { + "Currency": "RMB", + "Models": [ + { + "Provider": "my-anthropic", + "Model": "claude-3-5-sonnet", + "BaseModel": "claude-3-5-sonnet", + "Mode": "chat", + "Prices": { + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 0.000000375, + "cache_creation_input_token_cost": 0.00000375 + } + } + ] + } + } +} +``` + +### 6.2 多协议集群 + +若同一个 provider 同时支持 OpenAI 与 Claude 协议(如聚合平台),可配置: + +```json +"ModelProtocols": ["openai", "anthropic"] +``` + +BFE 会根据每个请求的 `AuthStyle` 自动选择认证头注入方式,不需要为不同协议创建不同集群。 + +--- + +## 7. 失败处理 + +| 场景 | 行为 | +|------|------| +| Anthropic 风格请求命中只支持 OpenAI 的集群 | 返回 400 `PROVIDER_PROTOCOL_MISMATCH` | +| OpenAI 风格请求命中只支持 Anthropic 的集群 | 返回 400 `PROVIDER_PROTOCOL_MISMATCH` | +| 同时存在 `Authorization` 和 `x-api-key` | 优先按 OpenAI 风格处理,保证现有客户端行为不变 | +| Anthropic 响应无 `total_tokens` | `UsedQuota` 由 `input_tokens + output_tokens` 推导 | + +--- + +## 8. 访问日志 + +新增字段: + +| 编号 | 字段名 | 类型 | 说明 | 采集模块 | +|------|--------|------|------|----------| +| 717 | `ai_protocol` | `string` | AI 协议/认证风格,如 `openai`、`anthropic` | `bfe_basic.GetApiKey` / `bfe_server/reverseproxy.go` | + +> `ai_provider` 保存用户自定义的 provider 名称,无法准确反映协议类型;新增 `ai_protocol` 后,一个标识"哪个 provider",一个标识"什么协议"。 + +--- + +## 9. 测试要点 + +### 9.1 单元测试 + +| 被测函数 | 场景 | +|---|---| +| `bfe_basic.GetApiKey` | `Authorization: Bearer xxx`;`x-api-key: xxx`;两者同时存在时的优先级;无 key | +| `bfe_basic.DetectAuthStyle` | `/v1/messages`、`x-api-key`、OpenAI 默认路径识别 | +| `mod_ai_token_auth.SetApiKey` | `openai` 风格生成 `Authorization: Bearer`;`anthropic` 风格生成 `x-api-key` | +| `mod_ai_token_auth.UpdateCtxByUsage` | OpenAI usage JSON;Claude usage JSON;混合字段时的优先级 | +| `mod_body_process.SSEEvent.GetQuotaUsage` | Claude 流式 usage 字段解析 | +| `mod_body_process.RawEvent.GetQuotaUsage` | Claude 非流式 usage 字段解析 | +| `bfe_server`(协议匹配) | Anthropic 风格请求命中 `model_protocols=["openai"]` 的集群返回 400;OpenAI 风格请求命中 `model_protocols=["anthropic"]` 的集群返回 400 | +| `mod_access_pb3` | `ai_protocol` 字段被正确填充为 `openai` 或 `anthropic` | + +### 9.2 集成测试 + +| 用例编号 | 场景 | 预期 | +|---|---|---| +| SC02-TC-XX | 客户端 `POST /v1/messages`,带 `x-api-key` | upstream 收到 `x-api-key` 为选中的 cluster key,`anthropic-version` 被自动注入 | +| SC02-TC-XX | Claude 非流式响应返回 `input_tokens`/`output_tokens` | 访问日志 `ai_input_tokens`/`ai_output_tokens`/`ai_total_tokens` 正确;访问日志 `ai_protocol` 为 `anthropic` | +| SC02-TC-XX | Claude 流式响应返回 usage | 配额扣减与访问日志正确;访问日志 `ai_protocol` 为 `anthropic` | +| SC02-TC-XX | OpenAI 风格请求 | 访问日志 `ai_protocol` 为 `openai` | +| SC02-TC-XX | Anthropic 风格请求命中只支持 OpenAI 的集群 | BFE 返回 400 `PROVIDER_PROTOCOL_MISMATCH`,upstream 不被调用 | +| SC02-TC-XX | OpenAI 风格请求命中只支持 Claude 的集群 | BFE 返回 400 `PROVIDER_PROTOCOL_MISMATCH`,upstream 不被调用 | +| SC02-TC-XX | 同集群同时命中 OpenAI 与 Claude 路径 | 不同认证头不互相污染 | + +--- + +## 10. 风险与回滚 + +| 风险 | 缓解措施 | +|---|---| +| 认证头冲突 | 明确优先级:`Authorization` > `x-api-key`,兼容现有 OpenAI 客户端 | +| `anthropic-version` 默认值过期 | 选择长期支持版本 `2023-06-01`;阶段一不引入额外配置字段 | +| 聚合平台模型名含 `anthropic/` 或 `claude` 但走 OpenAI 路径 | 不依赖 model 名判断,依赖路径/头/集群配置 | +| Claude 无 `total_tokens` | `UsedQuota` 由 `input + output` 推导 | +| 影响现有 OpenAI/DeepSeek 集群 | 默认 `AuthStyle == openai`,仅在明确识别为 Claude 时切换;`AIConf.ModelProtocols` 为空时默认只支持 OpenAI | +| 用 provider 名称硬编码协议风格 | provider 名称由用户自定义,不能再用 `"anthropic"` 判断;必须通过 `AIConf.ModelProtocols` 校验 | + +**回滚**:该设计修改的是 BFE 二进制行为。若线上需要恢复旧行为,需回滚代码并重新发版;影响面仅涉及 AI 网关路径。 + +--- + +## 11. 参考文档 + +- `bfe/docs/zh_cn/modifications/2026-08-20-claude-protocol-support/design-changes.md` +- `bfe/docs/zh_cn/sys_design/ai_access_log_fields.md` +- `bfe/docs/zh_cn/sys_design/multi_api_key.md` +- `bfe/docs/zh_cn/sys_design/provider_model_prefix_routing.md` +- `bfe/docs/zh_cn/sys_design/rmb_quota.md` +- `bfe-access-pb/RELEASE_NOTES_v0.3.4.md` +- `bfe-access-pb/docs/protobuf.md` diff --git a/docs/zh_cn/sys_design/multi_api_key.md b/docs/zh_cn/sys_design/multi_api_key.md index 774fa5945..0bcd93ee9 100644 --- a/docs/zh_cn/sys_design/multi_api_key.md +++ b/docs/zh_cn/sys_design/multi_api_key.md @@ -67,6 +67,7 @@ type AIConf struct { Keys []AIKey // multiple API keys; empty means no key injection KeyPolicy *AIKeyPolicy // key selection & retry policy ModelTable *ModelTable // pricing table, auto-filled by InnerAPI + ModelProtocols []string // supported protocols: openai / anthropic; empty defaults to ["openai"] } ``` @@ -105,7 +106,23 @@ ServeHTTPForAI() - **cluster 级 fallback**:由 `ServeHTTPForAI()` 控制,在 target 失败或后端 5xx 时切换到下一个 fallback cluster; - **Key 级重试**:由 `aiClusterInvoke()` 控制,在同一 cluster 内多个 API-Key 之间选择/重试。 -### 3.2 `aiClusterInvoke()` 改造 +### 3.2 协议风格识别与匹配 + +BFE 在 `doSingleAIForward()` 中根据请求特征识别协议/认证风格(`AuthStyle`): + +- 请求路径以 `/v1/messages` 开头 → `anthropic`; +- 请求头存在 `x-api-key` 且不存在 `Authorization` → `anthropic`; +- 否则默认 `openai`。 + +识别结果写入 `AiBasicInfo.AuthStyle`,并用于: + +1. **协议匹配校验**:比较 `AuthStyle` 与 `cluster.AIConf.ModelProtocols`,不匹配时直接返回 400 `PROVIDER_PROTOCOL_MISMATCH`; +2. **认证头注入**:`mod_ai_token_auth.SetApiKey()` 按 `AuthStyle` 注入 `Authorization: Bearer`(OpenAI)或 `x-api-key`(Anthropic); +3. **版本头注入**:Anthropic 风格下自动补 `anthropic-version: 2023-06-01`。 + +`AIConf.ModelProtocols` 为空时默认仅支持 `openai`,保证旧配置向后兼容。 + +### 3.3 `aiClusterInvoke()` 改造 `aiClusterInvoke()` 新增 Key 级重试循环。为支持重试,将单次转发逻辑抽取为 `doSingleAIForward()`: @@ -176,9 +193,21 @@ func (p *ReverseProxy) doSingleAIForward(srv *BfeServer, cluster *bfe_cluster.Bf } } - // apply cluster.AIConf (api key) - if cluster.AIConf != nil && selectedKey.Key != "" { - mod_ai_token_auth.SetApiKey(outreq, selectedKey.Key) + // apply cluster.AIConf (api key, provider, protocol) + if cluster.AIConf != nil { + if cluster.AIConf.Provider != "" { + aiMeta.Provider = cluster.AIConf.Provider + } + if selectedKey.Key != "" { + mod_ai_token_auth.SetApiKey(outreq, selectedKey.Key, aiMeta.AuthStyle) + } + } + + // Inject anthropic-version for Anthropic style requests if not present. + if aiMeta.AuthStyle == "anthropic" { + if outreq.Header.Get("anthropic-version") == "" { + outreq.Header.Set("anthropic-version", "2023-06-01") + } } // invoke cluster @@ -263,7 +292,7 @@ func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.Ser } ``` -### 3.3 Key 选择辅助函数 +### 3.4 Key 选择辅助函数 ```go // aiKeyAttemptState tracks key usage within one aiClusterInvoke call @@ -437,6 +466,7 @@ aiClusterInvoke: all ai keys exhausted for cluster[%s] "AIConf": { "Type": 0, "Provider": "deepseek", + "ModelProtocols": ["openai"], "Keys": [ { "Name": "key-primary", diff --git a/docs/zh_cn/sys_design/provider_model_prefix_routing.md b/docs/zh_cn/sys_design/provider_model_prefix_routing.md index 08e7a395c..30fb59c4c 100644 --- a/docs/zh_cn/sys_design/provider_model_prefix_routing.md +++ b/docs/zh_cn/sys_design/provider_model_prefix_routing.md @@ -53,6 +53,7 @@ type AIConf struct { Keys []AIKey // multiple API keys; empty means no key injection KeyPolicy *AIKeyPolicy // key selection & retry policy ModelTable *ModelTable // pricing table, auto-filled by InnerAPI + ModelProtocols []string // supported protocols: openai / anthropic; empty defaults to ["openai"] // 新增 MatchPrefix string `json:"MatchPrefix,omitempty"` // 例如 "openrouter/" @@ -226,6 +227,7 @@ ai-gateway-api 下发到 BFE 的 cluster 配置示例: "Provider": "openrouter", "MatchPrefix": "openrouter/", "StripPrefix": true, + "ModelProtocols": ["openai"], "Keys": [ { "Name": "default", diff --git a/docs/zh_cn/sys_design/rmb_quota.md b/docs/zh_cn/sys_design/rmb_quota.md index 57417a4cd..c42b59508 100644 --- a/docs/zh_cn/sys_design/rmb_quota.md +++ b/docs/zh_cn/sys_design/rmb_quota.md @@ -90,12 +90,13 @@ type ModelTable struct { } type AIConf struct { - Type int // 保留字段,当前应为 0 - ModelMapping *map[string]string // 模型映射 - Provider string // provider 名 - Keys []AIKey // 多 Key 模式 - KeyPolicy *AIKeyPolicy // Key 选择策略 - ModelTable *ModelTable // 成本定价表 + Type int // 保留字段,当前应为 0 + ModelMapping *map[string]string // 模型映射 + Provider string // provider 名 + Keys []AIKey // 多 Key 模式 + KeyPolicy *AIKeyPolicy // Key 选择策略 + ModelTable *ModelTable // 成本定价表 + ModelProtocols []string // provider 支持的协议:openai / anthropic;为空默认仅 openai } ``` diff --git a/go.mod b/go.mod index 4da92935f..851ea8deb 100644 --- a/go.mod +++ b/go.mod @@ -70,7 +70,7 @@ require ( require ( github.com/HdrHistogram/hdrhistogram-go v1.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/bfenetworks/bfe-access-pb v0.3.3 + github.com/bfenetworks/bfe-access-pb v0.3.4 github.com/bfenetworks/bfe-mock-waf v0.1.0 github.com/bfenetworks/bwi v0.1.2 github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index f1f5919b2..7e2dbc31a 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bfenetworks/bfe-access-pb v0.3.3 h1:8RjUhtoz32gbVQ2qlCdD7wjZfRFk+uYaTwyR4jHPWFg= -github.com/bfenetworks/bfe-access-pb v0.3.3/go.mod h1:3qunybhMV5hEeT/2qDwbchxo4t/9LPKEvwwZkmekRho= +github.com/bfenetworks/bfe-access-pb v0.3.4 h1:CFQlR78Utvb+c2T0pz1KQjZyurgo5tzoSWx4d996rXo= +github.com/bfenetworks/bfe-access-pb v0.3.4/go.mod h1:3qunybhMV5hEeT/2qDwbchxo4t/9LPKEvwwZkmekRho= github.com/bfenetworks/bfe-mock-waf v0.1.0 h1:dTd540S3nv6qNlG6lLC0F8qx3gyo5WB4wnci3+d4J78= github.com/bfenetworks/bfe-mock-waf v0.1.0/go.mod h1:MWZHbihiRQXpoUCvY1l18s2bfOBWx4N4pghxBt+xUv0= github.com/bfenetworks/bwi v0.1.2 h1:3AcCzUjyzKm+FeLgTIVg58u+SUdEVZdJbQM58Ezwjcg= diff --git a/tests/integration/common/mock_backend.go b/tests/integration/common/mock_backend.go index 5c958a7a8..89a8ca10c 100644 --- a/tests/integration/common/mock_backend.go +++ b/tests/integration/common/mock_backend.go @@ -58,11 +58,13 @@ type MockBackend struct { ResponseFunc func(r *http.Request, count int) (int, string) // ResponseHeaders, if non-nil, is written to the response before the status code. ResponseHeaders map[string]string - hits int - mu sync.Mutex - models []string - bodies [][]byte - authHeaders []string + hits int + mu sync.Mutex + models []string + bodies [][]byte + authHeaders []string + xApiKeyHeaders []string + anthropicVersions []string } // NewMockBackend starts a local HTTP server that returns the given status code. @@ -102,6 +104,8 @@ func NewMockBackend(clusterName string, response int, body string) *MockBackend b.mu.Lock() b.bodies = append(b.bodies, append([]byte(nil), bodyBytes...)) b.authHeaders = append(b.authHeaders, r.Header.Get("Authorization")) + b.xApiKeyHeaders = append(b.xApiKeyHeaders, r.Header.Get("x-api-key")) + b.anthropicVersions = append(b.anthropicVersions, r.Header.Get("anthropic-version")) var reqBody map[string]interface{} if err := json.Unmarshal(bodyBytes, &reqBody); err == nil { if model, ok := reqBody["model"].(string); ok { @@ -164,6 +168,20 @@ func (b *MockBackend) AuthHeaders() []string { return append([]string(nil), b.authHeaders...) } +// XApiKeyHeaders returns a deep copy of all observed x-api-key headers. +func (b *MockBackend) XApiKeyHeaders() []string { + b.mu.Lock() + defer b.mu.Unlock() + return append([]string(nil), b.xApiKeyHeaders...) +} + +// AnthropicVersions returns a deep copy of all observed anthropic-version headers. +func (b *MockBackend) AnthropicVersions() []string { + b.mu.Lock() + defer b.mu.Unlock() + return append([]string(nil), b.anthropicVersions...) +} + // Close shuts down the mock backend. func (b *MockBackend) Close() { b.server.Close() diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/sc06_claude_protocol_test.go b/tests/integration/implementation/scenario-SC06-claude-protocol-support/sc06_claude_protocol_test.go new file mode 100644 index 000000000..f2e85b7af --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/sc06_claude_protocol_test.go @@ -0,0 +1,428 @@ +// Copyright (c) 2026 The BFE Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sc06 + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/bfenetworks/bfe/bfe_config/bfe_cluster_conf/cluster_conf" + "github.com/bfenetworks/bfe/tests/integration/common" +) + +const ( + apiKey = "ak_user_a" + + openaiKey = "sk-openai-key" + anthropicKey = "sk-anthropic-key" + bothOpenAIKey = "sk-both-openai-key" + bothAnthKey = "sk-both-anthropic-key" + + hostOpenAI = "openai.example.org" + hostAnthropic = "anthropic.example.org" + hostBoth = "both.example.org" + + pathOpenAI = "/v1/chat/completions" + pathAnthropic = "/v1/messages" +) + +type testEnv struct { + t *testing.T + processEnv *common.ProcessEnv + backends map[string]*common.MockBackend + bfePort int + stopBFE func() +} + +func newTestEnv(t *testing.T) *testEnv { + e := &testEnv{ + t: t, + backends: make(map[string]*common.MockBackend), + } + + e.backends["cluster_openai_only"] = common.NewMockBackend("cluster_openai_only", http.StatusOK, + `{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`) + e.backends["cluster_anthropic_only"] = common.NewMockBackend("cluster_anthropic_only", http.StatusOK, + `{"content":[{"text":"ok"}],"usage":{"input_tokens":10,"output_tokens":5}}`) + e.backends["cluster_both_protocols"] = common.NewMockBackend("cluster_both_protocols", http.StatusOK, + `{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`) + + e.processEnv = common.NewProcessEnv(t) + e.processEnv.Build() + + confDir := filepath.Join(e.processEnv.WorkDir(), "conf") + logDir := filepath.Join(e.processEnv.WorkDir(), "log") + + aiConfs := map[string]*cluster_conf.AIConf{ + "cluster_openai_only": { + Type: 0, + ModelProtocols: []string{"openai"}, + Keys: []cluster_conf.AIKey{ + {Name: "openai-key", Key: openaiKey, Weight: 100}, + }, + }, + "cluster_anthropic_only": { + Type: 0, + ModelProtocols: []string{"anthropic"}, + Keys: []cluster_conf.AIKey{ + {Name: "anthropic-key", Key: anthropicKey, Weight: 100}, + }, + }, + "cluster_both_protocols": { + Type: 0, + ModelProtocols: []string{"openai", "anthropic"}, + Keys: []cluster_conf.AIKey{ + {Name: "both-openai-key", Key: bothOpenAIKey, Weight: 50}, + {Name: "both-anthropic-key", Key: bothAnthKey, Weight: 50}, + }, + }, + } + + builder := &common.BFEConfigBuilder{ + TemplateDir: "testdata", + TargetConfDir: confDir, + Backends: e.backends, + AIConfs: aiConfs, + } + if err := builder.Build(); err != nil { + t.Fatalf("build bfe config failed: %v", err) + } + + e.bfePort, _, e.stopBFE = e.processEnv.StartBFE(confDir, logDir) + return e +} + +func (e *testEnv) Close() { + if e.stopBFE != nil { + e.stopBFE() + } + for _, b := range e.backends { + b.Close() + } +} + +func (e *testEnv) logBFEException() { + data, err := os.ReadFile(filepath.Join(e.processEnv.WorkDir(), "log", "exception.log")) + if err == nil && len(data) > 0 { + e.t.Logf("bfe exception log:\n%s", string(data)) + } +} + +func (e *testEnv) sendRequest(host, path, authHeader, authValue string, body []byte) (*http.Response, string, error) { + url := fmt.Sprintf("http://127.0.0.1:%d%s", e.bfePort, path) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, "", err + } + req.Host = host + if authHeader != "" { + req.Header.Set(authHeader, authValue) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + return resp, string(respBody), nil +} + +func openaiBody() []byte { + return []byte(`{"model":"gpt-4"}`) +} + +func anthropicBody() []byte { + return []byte(`{"model":"claude-3-5-sonnet","messages":[{"role":"user","content":"hi"}]}`) +} + +// TestTC01 verifies that an OpenAI-style request is forwarded to an OpenAI-only +// cluster with Authorization: Bearer . +func TestTC01_OpenAIStyleForwarded(t *testing.T) { + e := newTestEnv(t) + defer e.Close() + + resp, body, err := e.sendRequest(hostOpenAI, pathOpenAI, "Authorization", "Bearer "+apiKey, openaiBody()) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + backend := e.backends["cluster_openai_only"] + if backend.Hits() != 1 { + t.Fatalf("expected 1 hit on openai-only cluster, got %d", backend.Hits()) + } + authHeaders := backend.AuthHeaders() + if len(authHeaders) != 1 || authHeaders[0] != "Bearer "+openaiKey { + t.Fatalf("expected Authorization 'Bearer %s', got %v", openaiKey, authHeaders) + } + xApiKeys := backend.XApiKeyHeaders() + if len(xApiKeys) != 1 || xApiKeys[0] != "" { + t.Fatalf("expected no x-api-key header, got %v", xApiKeys) + } +} + +// TestTC02 verifies that an Anthropic-style request is forwarded to an +// Anthropic-only cluster with x-api-key and anthropic-version headers. +func TestTC02_AnthropicStyleForwarded(t *testing.T) { + e := newTestEnv(t) + defer e.Close() + + resp, body, err := e.sendRequest(hostAnthropic, pathAnthropic, "x-api-key", apiKey, anthropicBody()) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + backend := e.backends["cluster_anthropic_only"] + if backend.Hits() != 1 { + t.Fatalf("expected 1 hit on anthropic-only cluster, got %d", backend.Hits()) + } + xApiKeys := backend.XApiKeyHeaders() + if len(xApiKeys) != 1 || xApiKeys[0] != anthropicKey { + t.Fatalf("expected x-api-key '%s', got %v", anthropicKey, xApiKeys) + } + authHeaders := backend.AuthHeaders() + if len(authHeaders) != 1 || authHeaders[0] != "" { + t.Fatalf("expected no Authorization header, got %v", authHeaders) + } + versions := backend.AnthropicVersions() + if len(versions) != 1 || versions[0] != "2023-06-01" { + t.Fatalf("expected anthropic-version '2023-06-01', got %v", versions) + } +} + +// TestTC03 verifies that an Anthropic-style request hitting an OpenAI-only +// cluster is rejected with 400 PROVIDER_PROTOCOL_MISMATCH. +func TestTC03_AnthropicRejectedByOpenAICluster(t *testing.T) { + e := newTestEnv(t) + defer e.Close() + + resp, body, err := e.sendRequest(hostOpenAI, pathAnthropic, "x-api-key", apiKey, anthropicBody()) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusBadRequest { + e.logBFEException() + t.Fatalf("expected status 400, got %d, body: %s", resp.StatusCode, body) + } + if !bytes.Contains([]byte(body), []byte("PROVIDER_PROTOCOL_MISMATCH")) { + t.Fatalf("expected PROVIDER_PROTOCOL_MISMATCH in body, got: %s", body) + } + if e.backends["cluster_openai_only"].Hits() != 0 { + t.Fatalf("expected openai-only cluster not hit, got %d", e.backends["cluster_openai_only"].Hits()) + } +} + +// TestTC04 verifies that an OpenAI-style request hitting an Anthropic-only +// cluster is rejected with 400 PROVIDER_PROTOCOL_MISMATCH. +func TestTC04_OpenAIRejectedByAnthropicCluster(t *testing.T) { + e := newTestEnv(t) + defer e.Close() + + resp, body, err := e.sendRequest(hostAnthropic, pathOpenAI, "Authorization", "Bearer "+apiKey, openaiBody()) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusBadRequest { + e.logBFEException() + t.Fatalf("expected status 400, got %d, body: %s", resp.StatusCode, body) + } + if !bytes.Contains([]byte(body), []byte("PROVIDER_PROTOCOL_MISMATCH")) { + t.Fatalf("expected PROVIDER_PROTOCOL_MISMATCH in body, got: %s", body) + } + if e.backends["cluster_anthropic_only"].Hits() != 0 { + t.Fatalf("expected anthropic-only cluster not hit, got %d", e.backends["cluster_anthropic_only"].Hits()) + } +} + +// TestTC05 verifies that a cluster supporting both protocols handles OpenAI +// and Anthropic requests with the correct authentication headers. +func TestTC05_BothProtocolsCluster(t *testing.T) { + e := newTestEnv(t) + defer e.Close() + + // OpenAI style + resp, body, err := e.sendRequest(hostBoth, pathOpenAI, "Authorization", "Bearer "+apiKey, openaiBody()) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200 for openai style, got %d, body: %s", resp.StatusCode, body) + } + + // Anthropic style + resp, body, err = e.sendRequest(hostBoth, pathAnthropic, "x-api-key", apiKey, anthropicBody()) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200 for anthropic style, got %d, body: %s", resp.StatusCode, body) + } + + backend := e.backends["cluster_both_protocols"] + if backend.Hits() != 2 { + t.Fatalf("expected 2 hits on both-protocols cluster, got %d", backend.Hits()) + } + + authHeaders := backend.AuthHeaders() + xApiKeys := backend.XApiKeyHeaders() + versions := backend.AnthropicVersions() + + foundOpenAI := false + foundAnthropic := false + for i := 0; i < backend.Hits(); i++ { + // OpenAI style: Authorization is set to one of the cluster keys, + // x-api-key is not set by BFE (may retain client value if present). + if authHeaders[i] == "Bearer "+bothOpenAIKey || authHeaders[i] == "Bearer "+bothAnthKey { + foundOpenAI = true + } + // Anthropic style: x-api-key is set to one of the cluster keys and + // anthropic-version is injected. + if (xApiKeys[i] == bothOpenAIKey || xApiKeys[i] == bothAnthKey) && versions[i] == "2023-06-01" { + foundAnthropic = true + } + } + if !foundOpenAI { + t.Fatalf("expected OpenAI-style Authorization header on both-protocols cluster, got auth=%v x-api-key=%v", + authHeaders, xApiKeys) + } + if !foundAnthropic { + t.Fatalf("expected Anthropic-style x-api-key and anthropic-version on both-protocols cluster, got auth=%v x-api-key=%v versions=%v", + authHeaders, xApiKeys, versions) + } +} + +// TestTC06 verifies that OpenAI-style Authorization takes precedence when both +// Authorization and x-api-key headers are present. +func TestTC06_AuthorizationPrecedence(t *testing.T) { + e := newTestEnv(t) + defer e.Close() + + url := fmt.Sprintf("http://127.0.0.1:%d%s", e.bfePort, pathOpenAI) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(openaiBody())) + if err != nil { + t.Fatalf("new request failed: %v", err) + } + req.Host = hostBoth + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("x-api-key", apiKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, string(respBody)) + } + + backend := e.backends["cluster_both_protocols"] + authHeaders := backend.AuthHeaders() + xApiKeys := backend.XApiKeyHeaders() + if len(authHeaders) != 1 { + t.Fatalf("expected one request to backend, got %d", backend.Hits()) + } + // When both Authorization and x-api-key are present, Authorization takes + // precedence for protocol/style detection, so BFE should inject the cluster + // key into Authorization (any cluster key is acceptable since key selection + // is weighted random). + if authHeaders[0] != "Bearer "+bothOpenAIKey && authHeaders[0] != "Bearer "+bothAnthKey { + t.Fatalf("expected Authorization to carry a cluster key when both headers present, got auth=%v x-api-key=%v", + authHeaders, xApiKeys) + } +} + +// TestTC07 verifies that Anthropic-style requests preserve an explicitly +// provided anthropic-version header. +func TestTC07_PreserveExplicitAnthropicVersion(t *testing.T) { + e := newTestEnv(t) + defer e.Close() + + url := fmt.Sprintf("http://127.0.0.1:%d%s", e.bfePort, pathAnthropic) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(anthropicBody())) + if err != nil { + t.Fatalf("new request failed: %v", err) + } + req.Host = hostAnthropic + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-02") + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, string(respBody)) + } + + versions := e.backends["cluster_anthropic_only"].AnthropicVersions() + if len(versions) != 1 || versions[0] != "2023-06-02" { + t.Fatalf("expected explicit anthropic-version preserved, got %v", versions) + } +} + +// TestTC08 verifies that Claude usage fields (input_tokens/output_tokens) are +// accepted and total_tokens is derived. +func TestTC08_ClaudeUsageFields(t *testing.T) { + e := newTestEnv(t) + defer e.Close() + + backend := e.backends["cluster_anthropic_only"] + backend.ResponseFunc = func(r *http.Request, count int) (int, string) { + return http.StatusOK, `{"content":[{"text":"ok"}],"usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":30,"cache_creation_input_tokens":20}}` + } + + resp, body, err := e.sendRequest(hostAnthropic, pathAnthropic, "x-api-key", apiKey, anthropicBody()) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if backend.Hits() != 1 { + t.Fatalf("expected 1 hit, got %d", backend.Hits()) + } +} diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/bfe.conf b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/bfe.conf new file mode 100644 index 000000000..5a232930e --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/bfe.conf @@ -0,0 +1,36 @@ +[server] +httpPort = 18080 +httpsPort = 18443 +monitorPort = 18081 +httpAddr = "127.0.0.1" +httpsAddr = "127.0.0.1" +monitorAddr = "127.0.0.1" +MonitorEnabled = true +maxCpus = 1 + +TlsHandshakeTimeout = 30 +ClientReadTimeout = 5 +ClientWriteTimeout = 5 +KeepAliveEnabled = true +GracefulShutdownTimeout = 10 + +EnableAiGateway = true + +accessibleBodySize = 4194304 + +# max total bytes of all active bytes_body buffers (0 means unlimited) +totalBodyBufferSize = 0 + +Modules = mod_ai_route + +hostRuleConf = server_data_conf/host_rule.data +routeRuleConf = server_data_conf/route_rule.data +vipRuleConf = server_data_conf/vip_rule.data + +clusterTableConf = cluster_conf/cluster_table.data +gslbConf = cluster_conf/gslb.data +clusterConf = cluster_conf/cluster_conf.data +NameConf = + +maxHeaderUriBytes = 8096 +maxHeaderBytes = 8096 diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/cluster_conf/gslb.data b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/cluster_conf/gslb.data new file mode 100644 index 000000000..105c21dbe --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/cluster_conf/gslb.data @@ -0,0 +1,18 @@ +{ + "clusters": { + "cluster_openai_only": { + "GSLB_BLACKHOLE": 0, + "sub_sub": 100 + }, + "cluster_anthropic_only": { + "GSLB_BLACKHOLE": 0, + "sub_sub": 100 + }, + "cluster_both_protocols": { + "GSLB_BLACKHOLE": 0, + "sub_sub": 100 + } + }, + "hostname": "gslb-test", + "ts": "20260720150000" +} diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/mod_ai_route/ai_route.data b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/mod_ai_route/ai_route.data new file mode 100644 index 000000000..3bd52e2e6 --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/mod_ai_route/ai_route.data @@ -0,0 +1,52 @@ +{ + "Version": "20260720150000", + "route_rules": { + "apikey_ak_user_a": { + "type": "apikey", + "owner": "ak_user_a", + "rules": [ + { + "name": "user_a-openai", + "Cond": "req_host_in(\"openai.example.org\")", + "targets": [ + { + "ClusterName": "cluster_openai_only", + "Model": "", + "Weight": 100 + } + ], + "fallbacks": [] + }, + { + "name": "user_a-anthropic", + "Cond": "req_host_in(\"anthropic.example.org\")", + "targets": [ + { + "ClusterName": "cluster_anthropic_only", + "Model": "", + "Weight": 100 + } + ], + "fallbacks": [] + }, + { + "name": "user_a-both", + "Cond": "req_host_in(\"both.example.org\")", + "targets": [ + { + "ClusterName": "cluster_both_protocols", + "Model": "", + "Weight": 100 + } + ], + "fallbacks": [] + } + ] + } + }, + "ApikeyRouteTableBindings": { + "ak_user_a": [ + "apikey_ak_user_a" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/mod_ai_route/mod_ai_route.conf b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/mod_ai_route/mod_ai_route.conf new file mode 100644 index 000000000..f250013bb --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/mod_ai_route/mod_ai_route.conf @@ -0,0 +1,5 @@ +[basic] +RouteRulePath = mod_ai_route/ai_route.data + +[log] +OpenDebug = true diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/server_data_conf/host_rule.data b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/server_data_conf/host_rule.data new file mode 100644 index 000000000..66c667ac2 --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/server_data_conf/host_rule.data @@ -0,0 +1,15 @@ +{ + "Version": "20260720150000", + "Hosts": { + "ai_product": [ + "openai.example.org", + "anthropic.example.org", + "both.example.org" + ] + }, + "HostTags": { + "ai_product": [ + "ai_product" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/server_data_conf/route_rule.data b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/server_data_conf/route_rule.data new file mode 100644 index 000000000..f1d2d8184 --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/server_data_conf/route_rule.data @@ -0,0 +1,12 @@ +{ + "Version": "20260720150000", + "BasicRule": { + "ai_product": [ + { + "Hostname": ["*"], + "Path": ["*"], + "ClusterName": "cluster_openai_only" + } + ] + } +} diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/server_data_conf/vip_rule.data b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/server_data_conf/vip_rule.data new file mode 100644 index 000000000..6fe22f03a --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/server_data_conf/vip_rule.data @@ -0,0 +1,8 @@ +{ + "Version": "20260720150000", + "Vips": { + "ai_vip": [ + "127.0.0.1" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/bfe_i_ca.crt b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/bfe_i_ca.crt new file mode 100644 index 000000000..f1e78f73c --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/bfe_i_ca.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDwDCCAqigAwIBAgIBCzANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzExMDMx +NDAxNDZaFw0zNzA3MTIxNDAxNDZaMIGQMQswCQYDVQQGEwJjbjEQMA4GA1UECAwH +YmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsMC3lpbmdmZWkt +ZGV2MRgwFgYDVQQDDA95aW5nZmVpLWRldi1pY2ExKTAnBgkqhkiG9w0BCQEWGmxp +YW5nY2h1YW5AeWYtbmV0d29ya3MuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA6eFuWgoknixrRO9NCX4jAKyLtcAOWVJqVN2yX7CxjZjLyPurjvTZ +W73NYUbrPAN4AB5gY6UAPzuiEOSopVVIZ0OschK0cJldu9vZ0mZObBOsovuFcLQq +dgNSJ5slJSk7tCgD2EnCB3GYPG4D+uIKYd0c49wzTWWv4bjDwpgnf0LQbFpy7GhN +7D59zFH4qgOK/IQ5vaTMGyvIvtWR5/1Gvc9MLpGopTgi0DiNLed4UwDYrod5kysl +q3UcB5puONHQISOVoD3uRxo7wdsmVsHUfW7YfAWkhi6ec8mx9fy8IyE6f7GlXnuV +ysNccwqyEotL5bOXPJCqwUCL1v+iKTMPWwIDAQABo1AwTjAdBgNVHQ4EFgQUCjvC +vDVr8QXh9/hMI5xBgNvoSFgwHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/oqHdW +1D0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAhIggK/6JK4N7+HZW +VoTemwRpilugZZyKrcVAHbiiwUfXVQVuI64vc+yHWMSFRD1mykHkKBFzxEoDabMl +ASBtUJNt4b4zEL9V7k295vmAOp2IdLUQxlgeKWqABm4DGX96tGR9nKQTcn1ZeAxx +NyQFV1aj+dnNcF1iFFNF6t0bRrOEZ/aRSiu1bWp3Dj1JYTjXbyyplh3Ktb8lv4lt +EmvCXjo/l4TgQC9233kcTkXcq1swppzkkXfhB0NVuf9DE3C2xAWX0b2FiIEqnAhl +Bx0Cn3RrX7PZ6qrdRL6oBKvGJV6DP8BlF4LXiuD1VN/waQFJha57KQ78ZYYIXxQg +WMjWgA== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/bfe_r_ca.crt b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/bfe_r_ca.crt new file mode 100644 index 000000000..2a6db1e49 --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/bfe_r_ca.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDkzCCAnugAwIBAgIBCjANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzExMDMx +MzQ5NTVaFw0zNzA3MTIxMzQ5NTVaMGQxCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEUMBIGA1UECwwLeWluZ2ZlaS1k +ZXYxFzAVBgNVBAMMDnlpbmdmZWktZGV2LWNhMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAvNA3HrsMjBcXrMIIhGVWsurIA1F9jxKeA7dh06H00Vt4inVV +SUvNFrTqgPRhLkAhGRMPxrjVRgJ5bbFqqXIuPIpUFBhUsWXIDH+oVXQl9jsxAXaG +gZ0lTO/uYR9qyrS1rj9nyNPwRf59Al/VlsQL71cNQ/T/agJ4PfvPfULTPLOsqclJ +hj0IgXmDj464dqcdG3ZdfXpfhNF6ab+8YjpwafTRmY+LoV8qjUwsYeJMcW4N8pxJ +8F2ktZj9J6uWepNGj+87ZeXg9XquzC62ASIFzPjoE1WN//Q518EqizxhuLGBDpK3 +6sEYUK4kHYUL4gZKFPlKTPXIl0ZsJIM5PsBMKwIDAQABo1AwTjAdBgNVHQ4EFgQU +DZloT8VhVbysD819oG/oqHdW1D0wHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/o +qHdW1D0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEALgB+IcN3UD4T ++4g8jaOyOiSUpUVdYqW+3go5DppqnvlGNq99N2cXKqssVPa/T9TikEcBEicFa8zU +bwlx6TEte+MkWfWdQxFR1EuI1FgKr3ps6ZBRr7MPpnYmI7K9aK372K9n7WrQhbmP +s7ult8bWB1/t6o3R7B9ChNkWT+7DPD4+FvB1GMJSGPno7cdnvDkevBOuC2DnQl3M ++ADFAge1Lo8wKBy6gYkNFd2BfarHGvRC5Qmmrme+RIpWZnvux1+lfXIInnfSTJRM +uAo/ePkoNsM3qQll6uEdhDxOMx8Pq94bCkM3DtI3ObuxWXjKCgUm/n9yetUvPj4U +iYhRUqHpUg== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_bfe_dev.crt b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_bfe_dev.crt new file mode 100644 index 000000000..2164fb602 --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_bfe_dev.crt @@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 18 (0x12) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=yingfei-dev-ca + Validity + Not Before: Jan 31 02:55:32 2024 GMT + Not After : Jan 28 02:55:32 2034 GMT + Subject: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=bfe-dev/emailAddress=dev@example.org + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:b2:7f:c1:0c:cd:49:43:d9:99:78:15:4c:5a:52: + a9:a7:bf:d5:eb:92:71:43:e1:37:e5:29:1b:68:f4: + 6f:4c:ea:fa:a4:8a:7c:29:01:2a:fa:7c:81:5b:c8: + eb:a1:40:94:b7:2b:82:e2:00:08:36:84:f0:b7:d2: + 5a:1e:56:97:aa:36:ff:4d:07:49:2d:fe:25:3a:f9: + e9:f1:ad:4e:6e:21:97:a9:f9:a2:a5:ac:82:23:0a: + d2:e0:97:cd:2f:14:b1:f0:8a:a8:e6:c5:97:45:94: + f8:8e:3f:96:66:5b:0b:8c:7c:07:61:18:44:92:f7: + 23:5a:c2:4b:88:58:59:5d:ca:5c:0d:6e:dd:ff:18: + 59:65:df:95:99:e3:3a:36:48:1f:3f:3a:e6:ce:85: + f3:0b:04:5e:92:ed:6f:8e:74:92:e4:37:46:da:5f: + 17:62:9c:82:40:06:fb:29:f8:55:f2:ba:23:75:ca: + 64:c0:45:03:12:bd:f5:17:15:7e:47:d5:bd:30:f2: + 99:ca:6b:e3:07:b0:ae:44:89:1e:10:26:ea:75:df: + 6f:07:b6:47:76:54:47:4f:6c:f6:68:fe:a8:cf:22: + 20:73:e8:19:55:8a:fe:f5:78:e8:51:88:52:80:1f: + 79:d4:c5:ae:8f:d2:2b:f6:41:01:42:01:cf:98:c2: + c9:25 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + EE:E8:68:42:DF:B1:F0:EF:6F:47:51:BD:D4:94:60:1F:05:85:A1:03 + X509v3 Authority Key Identifier: + keyid:0D:99:68:4F:C5:61:55:BC:AC:0F:CD:7D:A0:6F:E8:A8:77:56:D4:3D + + X509v3 Extended Key Usage: + TLS Web Client Authentication + Signature Algorithm: sha256WithRSAEncryption + a9:a6:26:8e:42:61:15:22:ee:fc:b5:e1:e4:6b:dd:ac:f5:15: + 11:39:10:9a:ca:6f:85:fd:cb:90:1c:b2:4f:fe:29:de:b0:e7: + 73:e2:f7:5e:63:8c:7f:c1:7e:75:2e:c9:9d:e9:c2:45:75:f3: + 27:ba:82:94:de:7f:6c:87:0c:5c:71:af:0f:14:00:68:35:f7: + 5a:4a:ff:f5:ef:35:dd:50:72:76:f0:6f:b6:7b:42:33:07:b4: + 24:44:0a:fd:9d:61:9e:44:e8:88:0f:02:76:c6:90:3f:9d:1b: + d8:3b:64:25:2a:a3:39:78:38:bd:20:89:4a:9c:bd:68:38:18: + 4c:cb:20:3a:9b:5b:5f:58:52:86:73:de:85:fe:d6:a1:c6:a7: + 86:b0:96:4b:fa:28:04:ad:5d:85:e8:a1:fc:ca:0f:3c:be:5c: + 90:7e:3e:84:ae:67:ee:9a:72:71:3c:b2:80:45:82:fc:7e:58: + 74:99:42:c5:c3:8a:4a:eb:e1:8b:d5:84:ce:25:aa:a1:75:79: + 94:66:ae:ee:df:30:15:0b:b5:c5:b1:2c:d5:0a:54:78:b6:2e: + 67:29:81:41:f6:16:49:31:96:e7:41:e1:99:6b:27:57:bb:7d: + 76:eb:e4:d5:59:aa:a2:5c:bd:1c:18:2a:fa:9d:28:1a:0b:b6: + bf:7d:58:1a +-----BEGIN CERTIFICATE----- +MIID7jCCAtagAwIBAgIBEjANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yNDAxMzEw +MjU1MzJaFw0zNDAxMjgwMjU1MzJaMH0xCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEUMBIGA1UECwwLeWluZ2ZlaS1k +ZXYxEDAOBgNVBAMMB2JmZS1kZXYxHjAcBgkqhkiG9w0BCQEWD2RldkBleGFtcGxl +Lm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ/wQzNSUPZmXgV +TFpSqae/1euScUPhN+UpG2j0b0zq+qSKfCkBKvp8gVvI66FAlLcrguIACDaE8LfS +Wh5Wl6o2/00HSS3+JTr56fGtTm4hl6n5oqWsgiMK0uCXzS8UsfCKqObFl0WU+I4/ +lmZbC4x8B2EYRJL3I1rCS4hYWV3KXA1u3f8YWWXflZnjOjZIHz865s6F8wsEXpLt +b450kuQ3RtpfF2KcgkAG+yn4VfK6I3XKZMBFAxK99RcVfkfVvTDymcpr4wewrkSJ +HhAm6nXfbwe2R3ZUR09s9mj+qM8iIHPoGVWK/vV46FGIUoAfedTFro/SK/ZBAUIB +z5jCySUCAwEAAaOBkTCBjjAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVu +U1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU7uhoQt+x8O9vR1G9 +1JRgHwWFoQMwHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/oqHdW1D0wEwYDVR0l +BAwwCgYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADggEBAKmmJo5CYRUi7vy14eRr +3az1FRE5EJrKb4X9y5Acsk/+Kd6w53Pi915jjH/BfnUuyZ3pwkV18ye6gpTef2yH +DFxxrw8UAGg191pK//XvNd1Qcnbwb7Z7QjMHtCRECv2dYZ5E6IgPAnbGkD+dG9g7 +ZCUqozl4OL0giUqcvWg4GEzLIDqbW19YUoZz3oX+1qHGp4awlkv6KAStXYXoofzK +Dzy+XJB+PoSuZ+6acnE8soBFgvx+WHSZQsXDikrr4YvVhM4lqqF1eZRmru7fMBUL +tcWxLNUKVHi2LmcpgUH2FkkxludB4ZlrJ1e7fXbr5NVZqqJcvRwYKvqdKBoLtr99 +WBo= +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem new file mode 100644 index 000000000..764aea3ad --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAsn/BDM1JQ9mZeBVMWlKpp7/V65JxQ+E35SkbaPRvTOr6pIp8 +KQEq+nyBW8jroUCUtyuC4gAINoTwt9JaHlaXqjb/TQdJLf4lOvnp8a1ObiGXqfmi +payCIwrS4JfNLxSx8Iqo5sWXRZT4jj+WZlsLjHwHYRhEkvcjWsJLiFhZXcpcDW7d +/xhZZd+VmeM6NkgfPzrmzoXzCwReku1vjnSS5DdG2l8XYpyCQAb7KfhV8rojdcpk +wEUDEr31FxV+R9W9MPKZymvjB7CuRIkeECbqdd9vB7ZHdlRHT2z2aP6ozyIgc+gZ +VYr+9XjoUYhSgB951MWuj9Ir9kEBQgHPmMLJJQIDAQABAoIBAFAKHzupVb/58+o3 +yqv5wx94Uuk2GlnwxIqaezL94GaiO0/K1U/huS7m426P0rDU75qPBTpn/0bLJ9GV +nllaRNnLnYEh0juwaWtfovp+1ttlbseGK9uUVip2cQbKqvQAmKWe14vbcDCAU1Ad +zUgKbUxKVVjBdAZekVjiJNJ3o2L9WPhf/uQo7A1XAJh2DajlTbgvrDM73W+47QuU +X4OHU0FMio6bxupu3OWl1bMrnKhuC4qczZWf2nOpcVQa89rtopuP4ENLJuWkbeGk +YQpNilEclnAa/Noumt/j/6GKC1EEHFsH2CNRRIazcZrsFhkSKc4pn1Y/WI3vj8kZ ++RYnJsUCgYEA6gr0x8suwRTAmVpoPk4XyP1x+eInG7onV5RjgsUtgruYd9naxRHg +2p8PHcv32pDs51Fa+4RldyMd/jec1SscRF5/+VOP9qeoRnaDqUu+uASgEO3OBxbP +JcWovyxRHIQxbYCQtqIr9bdzXw55MBLZou/sBUVTAkrIyPVyjWqZlV8CgYEAwz7L +YyYN615TsrzZKURxMjj94Nmob/NldSLRXaR3Ax7/ABtEOA685cwQxq7ONdkJTMIA +uR8u2GHZSzGiWnehuF6Zp7Xs71a57eFbs3ueZvvEba4Dff7hl7Y4tTlwrKndKjvP +J/5a2Ol8siQcRWAXHOdzggEHMSZ/sB4hWswly/sCgYEAhLRBpyemEwTZUBrbELjm +86gBgFajJi2fMSGKaxOygnYsNYjpauSAQnX99D87Aks6iM6wb/zaK3tV/lc6LgSL +uph6p7yh3JGj8JAyh0PTmDPHLtIoCAz+18QDsqJGO40ZGaXUaDn8Aw9J85QZUxDd +Jm4zvalZL+uHfarukRDolLECgYBUiupS4nWAh3XCnZeDEQna72avaFBROZmjIRJ7 +c+28wj009JmTlH4jGzvgbG0KUBKA1Div8Fq+g5AtyS498jNqvDvYrSQNdwZHhR/K +Fis++KHTxFfqxOU2Zkcj4d1yRpNn6EIJVVBNQL0n/g7n03XupCIWFw/gLoV343QZ +9vAe5QKBgA8ml59z1w3eUooc0yGfLhihXqCmM3IU006bFbODA30fBU4QKrHO8+Yx +Xbz9bi/1QLagLG6FzYQAkOjBlEBt5XLayYvwSb8xvWsm5A3vzTAbMFDOsDPEmRoH +dWtQccJcygOuK+PZtoZnNoJciNO5c9dZWD3xtIiURmX/kVtWrf6N +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_san_example.org.crt b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_san_example.org.crt new file mode 100644 index 000000000..78f3892b4 --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_san_example.org.crt @@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 15 (0xf) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=yingfei-dev-ca + Validity + Not Before: Dec 7 09:57:42 2023 GMT + Not After : Dec 4 09:57:42 2033 GMT + Subject: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei, CN=dev-test + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:d6:47:b0:17:69:91:4c:d0:66:c9:25:2e:38:f2: + 84:27:e2:7c:38:cc:04:b9:0c:8e:3d:cc:ef:4b:c5: + 35:2b:c1:82:d8:41:fc:25:c8:24:f2:e0:25:aa:f9: + 76:3c:e2:b2:50:fb:2d:ec:4e:16:d4:c1:da:1e:e3: + 51:9f:9d:c9:72:f9:cb:cc:14:9c:c1:82:c9:76:1f: + 98:dd:0e:b9:8a:20:d6:ab:f2:a6:f9:2f:23:81:f3: + af:e4:47:0c:55:95:94:de:ed:f6:a5:24:ee:32:e7: + 6b:79:e1:42:6f:a4:07:b2:95:1d:f5:a9:8e:60:42: + 70:42:bd:e2:30:18:68:74:52:32:98:a9:81:da:d8: + c6:6f:5e:1d:ce:79:b6:f3:ec:4f:ed:7d:22:57:d2: + 14:d0:fb:f2:50:d4:80:3b:89:ed:77:fd:45:6c:e6: + 52:6b:0a:52:71:ac:59:c8:d5:25:f5:40:03:fb:51: + b0:11:a7:00:79:d9:d8:4f:00:43:96:68:44:29:41: + dc:d2:cc:91:c8:61:95:41:4d:0e:66:5a:b5:15:67: + 3e:8a:6f:29:df:1c:8a:6f:ee:9e:97:9c:9e:69:71: + d3:34:52:75:e9:ea:e7:51:77:23:98:46:ca:47:a2: + d3:d3:97:03:41:4b:e3:33:11:72:2d:af:bf:2b:3e: + b3:51 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + 5A:27:32:9D:E7:36:24:A3:C1:DC:2F:95:80:C5:CF:0C:85:E8:E6:AF + X509v3 Authority Key Identifier: + keyid:0D:99:68:4F:C5:61:55:BC:AC:0F:CD:7D:A0:6F:E8:A8:77:56:D4:3D + + X509v3 Subject Alternative Name: + DNS:example.org, DNS:www.example.org, DNS:example.com, DNS:*.example.com, IP Address:127.0.0.1, IP Address:192.168.0.100 + Signature Algorithm: sha256WithRSAEncryption + 1e:e8:e8:8a:ad:a8:0e:fc:c9:82:00:a1:ab:30:3c:a5:b9:dc: + d6:fb:86:ad:30:52:7f:61:be:90:a6:b8:56:bb:f1:0b:e6:39: + 38:65:09:6b:da:83:f7:65:ff:c4:21:de:b4:9e:8b:bd:1e:1c: + d1:d5:94:b8:18:79:f2:d0:06:51:39:67:13:40:3b:73:5b:cb: + ea:de:c1:19:76:f8:7b:0f:15:51:61:49:fb:98:f7:ea:4f:fc: + c2:fb:a7:f4:3c:48:64:14:79:b5:78:5b:20:10:b5:7a:2d:4c: + 04:51:60:ec:20:10:19:26:5f:e2:fd:32:59:67:e9:3f:48:8d: + f5:52:12:01:81:2c:c0:e5:72:cd:7d:0a:eb:7a:05:df:a0:77: + b9:ba:9a:7d:d1:4b:6a:44:e4:2d:98:af:bd:77:2b:f5:ef:26: + 4b:75:b3:97:d0:3a:bc:07:21:ef:71:92:30:fe:a2:79:e5:56: + d7:7e:c2:f3:57:ab:d7:de:fc:97:ed:20:0c:9a:cb:c5:5d:00: + 3b:61:29:e8:00:d4:39:e0:f2:4e:a4:03:c2:12:52:ff:e7:78: + f9:f7:c0:12:dc:36:a4:05:a2:f0:6b:47:e2:21:3d:a2:e1:a1: + 91:c7:ac:8f:b8:ae:58:65:e0:2b:57:80:eb:77:2d:48:ef:e6: + fb:b9:e1:20 +-----BEGIN CERTIFICATE----- +MIIEBzCCAu+gAwIBAgIBDzANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzEyMDcw +OTU3NDJaFw0zMzEyMDQwOTU3NDJaMFoxCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEQMA4GA1UECwwHeWluZ2ZlaTER +MA8GA1UEAwwIZGV2LXRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQDWR7AXaZFM0GbJJS448oQn4nw4zAS5DI49zO9LxTUrwYLYQfwlyCTy4CWq+XY8 +4rJQ+y3sThbUwdoe41Gfncly+cvMFJzBgsl2H5jdDrmKINar8qb5LyOB86/kRwxV +lZTe7falJO4y52t54UJvpAeylR31qY5gQnBCveIwGGh0UjKYqYHa2MZvXh3Oebbz +7E/tfSJX0hTQ+/JQ1IA7ie13/UVs5lJrClJxrFnI1SX1QAP7UbARpwB52dhPAEOW +aEQpQdzSzJHIYZVBTQ5mWrUVZz6KbynfHIpv7p6XnJ5pcdM0UnXp6udRdyOYRspH +otPTlwNBS+MzEXItr78rPrNRAgMBAAGjgc0wgcowCQYDVR0TBAIwADAsBglghkgB +hvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYDVR0OBBYE +FFonMp3nNiSjwdwvlYDFzwyF6OavMB8GA1UdIwQYMBaAFA2ZaE/FYVW8rA/NfaBv +6Kh3VtQ9ME8GA1UdEQRIMEaCC2V4YW1wbGUub3Jngg93d3cuZXhhbXBsZS5vcmeC +C2V4YW1wbGUuY29tgg0qLmV4YW1wbGUuY29thwR/AAABhwTAqABkMA0GCSqGSIb3 +DQEBCwUAA4IBAQAe6OiKragO/MmCAKGrMDyludzW+4atMFJ/Yb6QprhWu/EL5jk4 +ZQlr2oP3Zf/EId60nou9HhzR1ZS4GHny0AZROWcTQDtzW8vq3sEZdvh7DxVRYUn7 +mPfqT/zC+6f0PEhkFHm1eFsgELV6LUwEUWDsIBAZJl/i/TJZZ+k/SI31UhIBgSzA +5XLNfQrregXfoHe5upp90UtqROQtmK+9dyv17yZLdbOX0Dq8ByHvcZIw/qJ55VbX +fsLzV6vX3vyX7SAMmsvFXQA7YSnoANQ54PJOpAPCElL/53j598AS3DakBaLwa0fi +IT2i4aGRx6yPuK5YZeArV4Drdy1I7+b7ueEg +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem new file mode 100644 index 000000000..393a79825 --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA1kewF2mRTNBmySUuOPKEJ+J8OMwEuQyOPczvS8U1K8GC2EH8 +Jcgk8uAlqvl2POKyUPst7E4W1MHaHuNRn53JcvnLzBScwYLJdh+Y3Q65iiDWq/Km ++S8jgfOv5EcMVZWU3u32pSTuMudreeFCb6QHspUd9amOYEJwQr3iMBhodFIymKmB +2tjGb14dznm28+xP7X0iV9IU0PvyUNSAO4ntd/1FbOZSawpScaxZyNUl9UAD+1Gw +EacAednYTwBDlmhEKUHc0syRyGGVQU0OZlq1FWc+im8p3xyKb+6el5yeaXHTNFJ1 +6ernUXcjmEbKR6LT05cDQUvjMxFyLa+/Kz6zUQIDAQABAoIBAC4sYGuLGf49Ygix +9FXdHFEj4rSyccoWRIhYoq/nHOAC4NkMzvKtQBj95+ABxVK1XstIdMrYwN6zrva8 +8Re9/mzCGwIs5uJj9ll30Y7A34Y+MUP4E7baS4JzKlG8ZZIDm4K2MFHBtXpOl8A5 +pAE+jVIUA9Kt6LohVuNq21SVzdxSfNYC/+SLqSftkWa/ZsqdkiHM5Hl+fVedh516 +IaLNW5hSthGh5n8dHY5h/AKPjfoq77aYp5/CUtJTC9mYdZu1j/W/pBVTRfOnwLQd +SQ1Xmr7f6q9Vmz+HnajIbFg9hQ54blvtUJ7DnugWxfUcoxf7ue79fnYjOIUOkRWw +8Iid/mECgYEA+5s0p0j+gkNZN5QtVNStoT04+1DqA1O381gczeaiz6Njt/MT0y5W +OpCsILQ70CpEjWAV+f6PDJiesDMxdGV+v2TCqK8ml8GahEczBLnHoAkPWCVf2XOX +oNj/CkZ2kmWufHFR+kcQbeDt1vFFcYUa61hKDFyNjinMW79Qy+PQID0CgYEA2gWd +7thE05sqU7/1MntmVRONKoAgnJmHcfSpWwLyh6E4YX3iKDSgI/9RnAMF39KUUY/O +XFWyIwAM9soeXknVsV/SmCaPeaEDiLHqz98aUEfvdLYMnuR883GgoXc4JrLsLw4z +oSi9lbAZFn0ekJ5L+rSFrY4rz9QZgYZxsLjUXKUCgYAbkaUSU2g3w8Np2J2i9u7T +hQ7SUsphdPHqAxSc5xGd6MxLYqIgeKpQHnwN1VHcfFUonIer7d2kxrBUpDdeBqT9 +ub+ulgqHhFo29ko70VNzUKrSwL2g6Q6LPFutt4zUe7nDvvL5loHRWF0XOTafurL5 +aKIsepO0KRZQU0U6IgszDQKBgFs6ZHaP2mTtFY4L0a75Ab3xu20gRgUhHRLq/H6P +wipMpMnuodaPBr9pU53Dig65D8T9Nq1eUnbgy4vs0T5FCPz6iqWN5RVQ8aieQhIP +WfRj1Wfx0WAfXcWEM2G9ACr5TWj3OVVjNclP8X9+hW6gPky+gv03c0+4gZ+4QRRg +ksPdAoGBAKXVv8L5Qt8rmi+QpF/6DVSeMB6lEevf319UEtyZPiYSBn1JJ9H+3Ddc +TMriXgZrw+PoXNJTAguDeGgzmtye1vraP0cjt7aG6sQ4YtMTnnuja880vK8z4i3Q +HJAi5fI8NvlW92dQBAccgrzFIKpRx+6CHwtCkNrxL2vJmGGzp8BR +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/certs/example.crt b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/certs/example.crt new file mode 100644 index 000000000..931874885 --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/certs/example.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEjCCAfqgAwIBAgIJAIAdu56fLE7OMA0GCSqGSIb3DQEBCwUAMBYxFDASBgNV +BAMMC2V4YW1wbGUub3JnMCAXDTIwMDYxMDEwMzM0NFoYDzIxMjAwNTE3MTAzMzQ0 +WjAWMRQwEgYDVQQDDAtleGFtcGxlLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBALv+LV1aWIlcK9rI7IuRS8SCusqBnoyJec/ErKiA2gbfgZ/YS73L +Zud84yp45AIqauzcI5q+hrkmsRZ7CKqDzrG+jHavW7jF+0laetJwRt26AcQcOtQD +2ik2O+Dl1WHAFn4vUAQxb+Xz6WfSaQN0QfM74z06XUDDsr7g7+NYtMzhf98SJSoK +ne/dVKJ3Bc6e6tvhnCRwPtix4ektEodK6WeNHYxwJ6wSZ8cRLzdxgjdD/4OGfFuj +dn8zbOi3SQt5ZqVbcDHUTzp5t0G8EoxnzotHhhzjSAmsypySqZXaxl3oX8aYUkFn +fCdg+WBXo5pOiNfoWh/D5bnIXWGp52yoy+kCAwEAAaNhMF8wHQYDVR0lBBYwFAYI +KwYBBQUHAwIGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFIH+0G3eCswQHbN06kvI80M3 +tNH9MB0GA1UdDgQWBBSxLHQE7gOEyfeSNc5uIO/G/rgjpzANBgkqhkiG9w0BAQsF +AAOCAQEAlGm5RwQ79xmLh3rj+5UViCSgsIuMcuhgIT4zogpo9S4uwXMqinrJhzRk +Oc2tb3y06XTAq1lMH2+58tqndAu8ni/UBz3OSghk2CTnZ1vxxXOd3CtQu4ypMq+k +qW0Umdrkk5TeAODNbrCy4c6vpICkQOljnRFWnDYu3aQ3JvaWZ/nObN7C72Lgpjfb +RfLXGmLsBCEIr028f9hpoeRCXoetUY2CiC2boAHR+cO6Jpvex4Jv5yYDpNKac52n +LLC8Cq5ozhOZNOSV6X9FpEca3rdhVUb0VgoNDCPZDdpO1PDJYCN9fUC8KUs+dsOh +SYGpliBaNsztoiJs1q5SkMQrMwmMmg== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/certs/example.key b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/certs/example.key new file mode 100644 index 000000000..b21c2f08d --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/certs/example.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAu/4tXVpYiVwr2sjsi5FLxIK6yoGejIl5z8SsqIDaBt+Bn9hL +vctm53zjKnjkAipq7Nwjmr6GuSaxFnsIqoPOsb6Mdq9buMX7SVp60nBG3boBxBw6 +1APaKTY74OXVYcAWfi9QBDFv5fPpZ9JpA3RB8zvjPTpdQMOyvuDv41i0zOF/3xIl +Kgqd791UoncFzp7q2+GcJHA+2LHh6S0Sh0rpZ40djHAnrBJnxxEvN3GCN0P/g4Z8 +W6N2fzNs6LdJC3lmpVtwMdRPOnm3QbwSjGfOi0eGHONICazKnJKpldrGXehfxphS +QWd8J2D5YFejmk6I1+haH8PluchdYannbKjL6QIDAQABAoIBAEUBL7WsjAMfihls +1ycD1kPzmIzstz3u2H+jOZ1AbsdHE1WRF3w7RTKDbP8SEN+aolT/GTKb7OfZg/c0 +giHU7/Hed8C47XoNcgei5qKIA/svY6aQlidsoo+uEJykwIZ488itpTlkzCYkOfCa +E2HpMqwNt4OqAMDdFKdr+aIB1Zu+KPBxW23WD9wEWAbe5LA4YnRF9kT4YZ6y9mce +dGaIf39VtBlrGMmvoU0LE9B79nyuebGi0svW6QDarBqaDrnM/N3fXgL1kk/gVfan +/xs6EA4qPxA5G4h+enYrIlZL0CbSj60nYElo+Z5nRdBaRdCF/bpXOLyK/kXWLUM0 +f2HTK+ECgYEA5cVcpJtczxEaxoaEUbppsW1LCrTjJDGTKJ63G7/lwqCxJeCHN185 +nnckHOW2287e19bu9aUmKJgRq5s1rXnT+MnCl/hQnfaMrKOKtzE+t7/zsc9+LuAr +pwJrtZ9Dcnwrk8NOE0fPjW5XpDCSoEOo7JWZmGTVlpabOgNkjocfp+sCgYEA0XPt +ZPt3F0wyzgLYRhgnvp5CV8SzQmulsW+ytnL5eiAcNSXqni3wQHN3PGxLInEyQwBQ +/M8TQpUbqGMmahCK4ZxMAwXMrpF0mVB8jfoYMou1FSYPlUV+CvLjWcTkZB1Nirez +VFXdtfHP0mx4PbYK2qjB03u4pPHAN8kuayIf2nsCgYAbv/FHZAgabfto3Jggcr4P +Ep8MhPolxeL69egxbsSl89hRNcO+2T5ROBxhbRDfjSV2tduYSUDJiEwiCJW8BMmn +814QEopR+ZPVyc6X/1eOw5z/7YpUyPgcrHsrrTdtHTf6GY1VYMfdUeU9zCv5NRKy +uAKb2Bm/nSLUJ9K+L+2PzwKBgQDRQgT3UtTUjehkMitpPFDY/LxDe92simfsMjBW +X+Anx1TnNI6GolbZzYJe98LJElao4fQH38raRqZvQT/rz8MxTDoU+wJXljLryaHn +Jupt9W5hRrli5R7cSXYjBbc43p3N7WJY68CqOoDrNjubS/jkJJ4hcAY1pOHp2jFq +D5nLaQKBgAysU6O5kJ8yKxhbZflb42MqKCFBGrbRnbYx14PAEZOaRhzxehpppQmx +RLbn/z1Uh5Ms28ipxA+vnhyM3FcU5lKboaFyWJeuNslw0FxEcIai6hL6UkDznS4G +aqyzUjpG5Chg0x18xWYCbiGJwjZ9BWhtH+jojm856QHGzeQJWVoo +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/client_ca/example_ca.crt b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/client_ca/example_ca.crt new file mode 100644 index 000000000..b0fa2fa28 --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/client_ca/example_ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDATCCAemgAwIBAgIJAMsPuHg4mqnaMA0GCSqGSIb3DQEBCwUAMBYxFDASBgNV +BAMMC2V4YW1wbGUub3JnMCAXDTIwMDYxMDEwMzMwNloYDzIxMjAwNTE3MTAzMzA2 +WjAWMRQwEgYDVQQDDAtleGFtcGxlLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBAL72D2gOnJN9Zvo9KjruwM1EsFe3xZRJ0NvZ5bHd6+5jhlgCAhQ+ +AGb7ufEiYOi2JWHl2Bkq0iVrp+zv0RLdq0oVjX+OG5H2yWbnC7ifbNjir93LX0un +tIqv5CIbExDSBRkufxfV37yjXdrcMqYSbD2Kw3PfAbWs1Dego8fRz8QAp5+LCvW2 +BZZyYi6JzhCAUW1+8OQPyzOhB50eSJiS5xgVA7wkwmYeVUpHqU8sv4VzjM3bmUc7 +1mPLlnRVIqScrqYgQ9Ou21vZebOJ8+ckVL8O3XHhMZlssBbWiBFZZnaNWbzcEI90 +oiW4YAQ5t7gaXuCVvaiNvq2VZknarR6AxcsCAwEAAaNQME4wHQYDVR0OBBYEFIH+ +0G3eCswQHbN06kvI80M3tNH9MB8GA1UdIwQYMBaAFIH+0G3eCswQHbN06kvI80M3 +tNH9MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJtHx8ibLp+CYDR1 +ZVeQ3qg4OeRL0q21+2EgBJq6zOUt/9SxThA80aXJ8CYH9dnCW+fOnpEk1xFWxtXc +FwSLsnqAdwOJaaQWoMzhyqjZV5x5G9+MW5FzGGOdes2md2Z+tAwMoV9TVtxZkbKy +mC2tDJdvgLgt9/YcbUcZPDbyZojdZ+UbATm+Lro9dhTXt91vsAgz5QA9e08rQVkF +pc9+ZQ5zxBsoblQ+ozPOWOdV4zJVx+wQsAnOG2qU0yVQAscGsTo4wnzFrAU54fO7 +Lh4cOrY0P1/o65yiSzwK7f0jwBeT/jEfMOrJ7pPo7doUov0iVj0SZyTM3HFa2Mzj +2zGTk0o= +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/client_ca/example_ca.key b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/client_ca/example_ca.key new file mode 100644 index 000000000..4f7ad13b1 --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/client_ca/example_ca.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAvvYPaA6ck31m+j0qOu7AzUSwV7fFlEnQ29nlsd3r7mOGWAIC +FD4AZvu58SJg6LYlYeXYGSrSJWun7O/REt2rShWNf44bkfbJZucLuJ9s2OKv3ctf +S6e0iq/kIhsTENIFGS5/F9XfvKNd2twyphJsPYrDc98BtazUN6Cjx9HPxACnn4sK +9bYFlnJiLonOEIBRbX7w5A/LM6EHnR5ImJLnGBUDvCTCZh5VSkepTyy/hXOMzduZ +RzvWY8uWdFUipJyupiBD067bW9l5s4nz5yRUvw7dceExmWywFtaIEVlmdo1ZvNwQ +j3SiJbhgBDm3uBpe4JW9qI2+rZVmSdqtHoDFywIDAQABAoIBAD60bbqtkZycwQPK +seNIIudEduNW5PocgwiuNE6DoMVWyPZ9MlGTSm6GmjgkIc5IgV30K1GYTgkboLic +xvp675QUH7KS51q2vsubcq3dK9DMHxOlhFVDbHVd7HuGiGwtip8KNZGOGTnIKzmC +tN7zjbdnqWaTA+y0I7tgdGdY7fBd9Rzgaq+OlPq2u33HvWHvlG/7PfpZuXB5YLgd +m04l7LJ7ikhIjycg7j27v/4c6xCiH5jMJKsZ+nfsQ0kEEo9DkhcKInK+wHsMzKsH +Cy3AdlE0IRsbxRAoMumVs2g5u90m3zBPkRrNdZ2Ni7BesnhxbkIqvb4SfpxKyuhK +fADfZgECgYEA7SUIS2gII0TGjXh0h16d+eoLVOz0eVpgF3XXxmgtAPu10dqxVEC2 +j5FSBCgZhqZ3axVotP71c2mT+hF+Mqy4TLMfA/B9jKLXjZlPbg4EcAgI7tALskwz +Bk5BkX0k825bU9P0j+AlpLx6/ztHr2N9/cKZfqQVrO9t+FRusvAHkHsCgYEAziT8 +F30Ch2s6IJngCj5jH164iN2CoFXjqPNVgRj45gLE3zrf1R7u2JTeEhjWNLZ6IWZ3 +G/bT7eYm6x8u7LFlORdnWKsHlftGu0igRyvIGcxoHgjXlsLidBaEP+HlOLUtTumu +MfQJUozLcrOBIV6m9VhPnSDTeCg/tOqy68V2pvECgYAUYgd5e8KfTW0Hgd/6Nq67 +aVt5/DfzKkpyGcXnHtMnb3ssQ3DUfg9y/ZmgE9ZF1Y8UHC34yKVOOzfl2ZUQQ/o/ +VXIIA6a27NQ8Ln4+RmQpQPeLl0Q6GgSUuSs3lxsS9VxSMzilGS4DH9QulejOcW3F +3vEUioP2bkn0e0VcifcMewKBgAG1Pr13FLFIiye//qI3GB0nbMH9i9qGO6entHqo +WU+WkEkFNNuQMQxsV1axC/1N0b87GRuLNQBQmtvx2zKs2Zjaf8m1SQ/OECz3EhTk +4PiNwAMXsamXHcc2dIwO9BY/MgvoVcAmNHmRnxHpONWs8hcwTyCPKBFjy/tUwny/ +mxcRAoGAcNRxLZlyRqmQ6zGf41GK4ZIR9gix0L6Km49S1maGFmcbctOR2GcQN8Eo +f38rkrFBfBfuFSGzghJiXDvKORg9r3V/bzSKcXkprJra6hzn5vn+t7wurjzaJUlK +zUW5dl3SU2bC4MK+X7bwqf9jm9b7FXSt4p1xly8Uh/Mufwi7zOM= +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/server_cert_conf.data b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/server_cert_conf.data new file mode 100644 index 000000000..49f228531 --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/server_cert_conf.data @@ -0,0 +1,12 @@ +{ + "Version": "init version", + "Config": { + "Default": "example.org", + "CertConf": { + "example.org": { + "ServerCertFile": "tls_conf/certs/example.crt", + "ServerKeyFile" : "tls_conf/certs/example.key" + } + } + } +} diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/session_ticket_key.data b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/session_ticket_key.data new file mode 100644 index 000000000..b3d2356b0 --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/session_ticket_key.data @@ -0,0 +1,4 @@ +{ + "Version": "init version", + "SessionTicketKey": "08a0d852ef494143af613ef32d3c39314758885f7108e9ab021d55f422a454f7c9cd5a53978f48fa1063eadcdc06878f" +} diff --git a/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/tls_rule_conf.data b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/tls_rule_conf.data new file mode 100644 index 000000000..66e7b5dbb --- /dev/null +++ b/tests/integration/implementation/scenario-SC06-claude-protocol-support/testdata/tls_conf/tls_rule_conf.data @@ -0,0 +1,20 @@ +{ + "Version": "12", + "DefaultNextProtos": ["http/1.1"], + "Config": { + "example_product": { + "VipConf": [ + "10.199.4.14" + ], + "SniConf": ["example.org"], + "CertName": "example.org", + "NextProtos": [ + "h2;rate=100;isw=65535;mcs=200;level=0", + "http/1.1" + ], + "Grade": "C", + "ClientAuth": false, + "ClientCAName": "example_ca" + } + } +} diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/\345\234\272\346\231\257\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/\345\234\272\346\231\257\350\257\264\346\230\216.md" new file mode 100644 index 000000000..b74a25f11 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/\345\234\272\346\231\257\350\257\264\346\230\216.md" @@ -0,0 +1,287 @@ +# SC06 Claude 协议支持 + +## 1. 场景背景与目的 + +BFE AI 网关在 v0.5 起支持识别并转发 Anthropic Claude Messages API 请求。本场景验证: + +- BFE 能根据请求路径/头自动识别 `openai` 与 `anthropic` 协议风格; +- 按协议风格向上游注入正确的认证头(`Authorization: Bearer` 或 `x-api-key`); +- Anthropic 风格自动补 `anthropic-version: 2023-06-01`; +- `AIConf.ModelProtocols` 用于校验请求协议与集群 provider 支持协议是否匹配,不匹配时直接拒绝; +- 多协议集群可同时支持 OpenAI 与 Anthropic 请求; +- Claude 响应 usage 字段(`input_tokens` / `output_tokens` / `cache_read_input_tokens` / `cache_creation_input_tokens`)被正确解析。 + +## 2. 运行模式 + +- **单组件模式**:仅启动真实 `bfe` 进程。 +- 不涉及 `ai-gateway-api` 与 `conf-agent`。 + +## 3. 涉及的 BFE 配置文件 + +| 文件 | 说明 | +|------|------| +| `bfe.conf` | 启用 `EnableAiGateway`,加载 `mod_ai_route` | +| `mod_ai_route/mod_ai_route.conf` | 指定 `ai_route.data` 路径 | +| `mod_ai_route/ai_route.data` | 定义 3 条 apikey 级路由规则,按 Host 路由到不同 cluster | +| `server_data_conf/host_rule.data` | 声明 `ai_product` 产品线对应的 3 个 Host | +| `server_data_conf/route_rule.data` | 默认路由(本场景不命中) | +| `cluster_conf/cluster_conf.data` | 运行时根据 mock 后端与 `AIConfs` 动态生成 | +| `cluster_conf/gslb.data` | GSLB 权重配置 | +| `cluster_conf/cluster_table.data` | 运行时根据 mock 后端地址动态生成 | + +## 4. 测试 Cluster 与 Key 配置 + +### 4.1 cluster_openai_only + +```json +{ + "AIConf": { + "Type": 0, + "ModelProtocols": ["openai"], + "Keys": [ + {"Name": "openai-key", "Key": "sk-openai-key", "Weight": 100} + ] + } +} +``` + +### 4.2 cluster_anthropic_only + +```json +{ + "AIConf": { + "Type": 0, + "ModelProtocols": ["anthropic"], + "Keys": [ + {"Name": "anthropic-key", "Key": "sk-anthropic-key", "Weight": 100} + ] + } +} +``` + +### 4.3 cluster_both_protocols + +```json +{ + "AIConf": { + "Type": 0, + "ModelProtocols": ["openai", "anthropic"], + "Keys": [ + {"Name": "both-openai-key", "Key": "sk-both-openai-key", "Weight": 50}, + {"Name": "both-anthropic-key", "Key": "sk-both-anthropic-key", "Weight": 50} + ] + } +} +``` + +> 说明:多协议 cluster 中配置了两个 key,分别用于 OpenAI 与 Anthropic 请求验证,避免单 key 被两种请求复用导致断言混淆。 + +### 4.4 路由表设计 + +#### apikey_ak_user_a + +| 规则名 | 条件 | targets | fallbacks | +|--------|------|---------|-----------| +| user_a-openai | `req_host_in("openai.example.org")` | `cluster_openai_only` | 无 | +| user_a-anthropic | `req_host_in("anthropic.example.org")` | `cluster_anthropic_only` | 无 | +| user_a-both | `req_host_in("both.example.org")` | `cluster_both_protocols` | 无 | + +#### ApikeyRouteTableBindings + +```json +{ + "ak_user_a": ["apikey_ak_user_a"] +} +``` + +## 5. 测试例列表 + +| 编号 | 名称 | 核心验证点 | +|------|------|------------| +| TC-01 | OpenAI 风格转发 | OpenAI 请求命中 `cluster_openai_only`,upstream 收到 `Authorization: Bearer sk-openai-key` | +| TC-02 | Anthropic 风格转发 | Claude 请求命中 `cluster_anthropic_only`,upstream 收到 `x-api-key: sk-anthropic-key` 与 `anthropic-version: 2023-06-01` | +| TC-03 | Anthropic 请求命中 OpenAI-only 集群 | 返回 400 `PROVIDER_PROTOCOL_MISMATCH`,upstream 不被调用 | +| TC-04 | OpenAI 请求命中 Anthropic-only 集群 | 返回 400 `PROVIDER_PROTOCOL_MISMATCH`,upstream 不被调用 | +| TC-05 | 多协议集群同时支持两种请求 | `cluster_both_protocols` 对 OpenAI 请求注入 `Authorization: Bearer`,对 Claude 请求注入 `x-api-key` 与 `anthropic-version` | +| TC-06 | 双头存在时 Authorization 优先 | 同时带 `Authorization` 与 `x-api-key` 时按 OpenAI 风格处理 | +| TC-07 | 显式 anthropic-version 被保留 | 客户端已传 `anthropic-version` 时,BFE 不覆盖 | +| TC-08 | Claude usage 字段解析 | Claude 响应中的 `input_tokens` / `output_tokens` / cache 字段被接受,请求正常返回 200 | + +## 5.1 测试例详细说明 + +### TC-01 OpenAI 风格转发 + +**目的**:验证 OpenAI 风格请求被正确路由到仅支持 `openai` 协议的集群,并使用 `Authorization: Bearer` 注入集群 key。 + +**请求构造**: + +| 字段 | 值 | +|------|-----| +| Host | `openai.example.org` | +| Path | `/v1/chat/completions` | +| Header | `Authorization: Bearer ak_user_a` | +| Body | `{"model":"gpt-4"}` | + +**预期结果**: + +- 响应状态码 200。 +- `cluster_openai_only` mock backend 命中 1 次。 +- upstream 收到的 `Authorization` 头为 `Bearer sk-openai-key`。 +- upstream 未收到 `x-api-key` 头。 + +### TC-02 Anthropic 风格转发 + +**目的**:验证 Anthropic 风格请求被正确路由到仅支持 `anthropic` 协议的集群,并使用 `x-api-key` 注入集群 key,同时自动补 `anthropic-version`。 + +**请求构造**: + +| 字段 | 值 | +|------|-----| +| Host | `anthropic.example.org` | +| Path | `/v1/messages` | +| Header | `x-api-key: ak_user_a` | +| Body | `{"model":"claude-3-5-sonnet","messages":[{"role":"user","content":"hi"}]}` | + +**预期结果**: + +- 响应状态码 200。 +- `cluster_anthropic_only` mock backend 命中 1 次。 +- upstream 收到的 `x-api-key` 头为 `sk-anthropic-key`。 +- upstream 收到的 `anthropic-version` 头为 `2023-06-01`。 +- upstream 未收到 `Authorization` 头。 + +### TC-03 Anthropic 请求命中 OpenAI-only 集群 + +**目的**:验证请求协议与集群 `ModelProtocols` 不匹配时,BFE 直接拒绝且不访问 upstream。 + +**请求构造**: + +| 字段 | 值 | +|------|-----| +| Host | `openai.example.org` | +| Path | `/v1/messages` | +| Header | `x-api-key: ak_user_a` | +| Body | `{"model":"claude-3-5-sonnet","messages":[{"role":"user","content":"hi"}]}` | + +**预期结果**: + +- 响应状态码 400。 +- 响应体包含 `PROVIDER_PROTOCOL_MISMATCH`。 +- `cluster_openai_only` mock backend 命中 0 次。 + +### TC-04 OpenAI 请求命中 Anthropic-only 集群 + +**目的**:验证 OpenAI 风格请求命中仅支持 `anthropic` 的集群时同样被拒绝。 + +**请求构造**: + +| 字段 | 值 | +|------|-----| +| Host | `anthropic.example.org` | +| Path | `/v1/chat/completions` | +| Header | `Authorization: Bearer ak_user_a` | +| Body | `{"model":"gpt-4"}` | + +**预期结果**: + +- 响应状态码 400。 +- 响应体包含 `PROVIDER_PROTOCOL_MISMATCH`。 +- `cluster_anthropic_only` mock backend 命中 0 次。 + +### TC-05 多协议集群同时支持两种请求 + +**目的**:验证 `ModelProtocols` 同时包含 `openai` 与 `anthropic` 的集群能够按请求风格选择正确的 key 与头部。 + +**请求构造**:向 `both.example.org` 连续发送 2 个请求: + +1. OpenAI 风格:`POST /v1/chat/completions`,`Authorization: Bearer ak_user_a`,body `{"model":"gpt-4"}`。 +2. Anthropic 风格:`POST /v1/messages`,`x-api-key: ak_user_a`,body `{"model":"claude-3-5-sonnet","messages":...}`。 + +**预期结果**: + +- 两个请求均返回 200。 +- `cluster_both_protocols` mock backend 命中 2 次。 +- OpenAI 请求 upstream 收到 `Authorization: Bearer sk-both-openai-key`(或对应 Anthropic key 以 Bearer 形式注入,取决于加权随机选中的 key)。 +- Anthropic 请求 upstream 收到 `x-api-key: sk-both-anthropic-key` 与 `anthropic-version: 2023-06-01`。 + +> 说明:由于加权随机选 key,断言策略为“存在符合协议风格的 header 即可”,不强制固定某一把 key。 + +### TC-06 双头存在时 Authorization 优先 + +**目的**:验证请求同时携带 `Authorization` 与 `x-api-key` 时,BFE 按 OpenAI 风格处理(`DetectAuthStyle` 优先识别 Authorization)。 + +**请求构造**: + +| 字段 | 值 | +|------|-----| +| Host | `both.example.org` | +| Path | `/v1/chat/completions` | +| Header | `Authorization: Bearer ak_user_a`、`x-api-key: ak_user_a` | +| Body | `{"model":"gpt-4"}` | + +**预期结果**: + +- 响应状态码 200。 +- upstream 收到的 `Authorization` 头携带集群 key(`Bearer sk-both-openai-key` 或 `Bearer sk-both-anthropic-key`)。 + +### TC-07 显式 anthropic-version 被保留 + +**目的**:验证客户端显式传递的 `anthropic-version` 不会被 BFE 的默认值覆盖。 + +**请求构造**: + +| 字段 | 值 | +|------|-----| +| Host | `anthropic.example.org` | +| Path | `/v1/messages` | +| Header | `x-api-key: ak_user_a`、`anthropic-version: 2023-06-02` | +| Body | `{"model":"claude-3-5-sonnet","messages":[{"role":"user","content":"hi"}]}` | + +**预期结果**: + +- 响应状态码 200。 +- upstream 收到的 `anthropic-version` 头仍为 `2023-06-02`。 + +### TC-08 Claude usage 字段解析 + +**目的**:验证 Claude 响应中的 `input_tokens`、`output_tokens` 及 cache 相关字段可被 BFE 正常解析,不影响响应返回。 + +**请求构造**: + +| 字段 | 值 | +|------|-----| +| Host | `anthropic.example.org` | +| Path | `/v1/messages` | +| Header | `x-api-key: ak_user_a` | +| Body | `{"model":"claude-3-5-sonnet","messages":[{"role":"user","content":"hi"}]}` | + +**Mock 后端响应**: + +```json +{ + "content": [{"text":"ok"}], + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "cache_read_input_tokens": 30, + "cache_creation_input_tokens": 20 + } +} +``` + +**预期结果**: + +- 响应状态码 200。 +- `cluster_anthropic_only` mock backend 命中 1 次。 +- BFE 不报错、不中断响应。 + +## 6. 公共基础设施改造 + +本场景扩展公共测试框架: + +1. **`common/mock_backend.go`**:新增 `x-api-key` 与 `anthropic-version` 头记录能力,以及 `XApiKeyHeaders()` / `AnthropicVersions()` 查询方法。 + +## 7. 与其他场景的依赖关系 + +- 依赖 SC01 已验证的 `mod_ai_route` 路由表查找与绑定能力; +- 不依赖 SC02 的 Key 级重试逻辑,本场景每个 cluster 仅配置单 Key(多协议 cluster 配置两个 key 仅用于区分协议)。 From cb51c629b8538280efe01c8c4643d2f907727a6f Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Mon, 24 Aug 2026 13:35:13 +0800 Subject: [PATCH 07/18] feat(bfe): stable redis key for AI rate limit and add integration tests - Use control-plane generated redis_key for RPM/TPM limiter keys - Fallback to original name-based key when redis_key absent - Ignore max_concurrency=0 to avoid rejecting all requests - Add SC07 integration tests for redis key stability - Sync Chinese and English configuration docs Signed-off-by: zhangmiao --- bfe_modules/mod_ai_rate_limit/data_load.go | 6 + .../mod_ai_rate_limit/data_load_test.go | 16 + .../mod_ai_rate_limit/policy_limiter.go | 26 +- .../mod_ai_rate_limit/policy_limiter_test.go | 76 ++++ conf/mod_ai_rate_limit/ai_rate_limit.data | 14 +- .../mod_ai_rate_limit/ai_rate_limit.data.md | 10 +- docs/zh_cn/SUMMARY.md | 1 + .../mod_ai_rate_limit/ai_rate_limit.data.md | 10 +- .../sys_design/ai_rate_limit_redis_key.md | 235 ++++++++++++ .../integration/common/bfe_config_builder.go | 64 +++- .../sc07_ai_rate_limit_redis_key_test.go | 341 ++++++++++++++++++ .../testdata/bfe.conf | 38 ++ .../testdata/cluster_conf/gslb.data | 10 + .../mod_ai_rate_limit/mod_ai_rate_limit.conf | 14 + .../testdata/mod_ai_route/ai_route.data | 28 ++ .../testdata/mod_ai_route/mod_ai_route.conf | 5 + .../mod_ai_token_auth/mod_ai_token_auth.conf | 13 + .../testdata/server_data_conf/host_rule.data | 13 + .../testdata/server_data_conf/route_rule.data | 12 + .../testdata/server_data_conf/vip_rule.data | 8 + .../testdata/tls_conf/backend_rs/bfe_i_ca.crt | 23 ++ .../testdata/tls_conf/backend_rs/bfe_r_ca.crt | 22 ++ .../tls_conf/backend_rs/r_bfe_dev.crt | 85 +++++ .../tls_conf/backend_rs/r_bfe_dev_prv.pem | 27 ++ .../tls_conf/backend_rs/r_san_example.org.crt | 85 +++++ .../backend_rs/r_san_example.org_prv.pem | 27 ++ .../testdata/tls_conf/certs/example.crt | 19 + .../testdata/tls_conf/certs/example.key | 27 ++ .../tls_conf/client_ca/example_ca.crt | 19 + .../tls_conf/client_ca/example_ca.key | 27 ++ .../testdata/tls_conf/server_cert_conf.data | 12 + .../testdata/tls_conf/session_ticket_key.data | 4 + .../testdata/tls_conf/tls_rule_conf.data | 20 + 33 files changed, 1315 insertions(+), 22 deletions(-) create mode 100644 docs/zh_cn/sys_design/ai_rate_limit_redis_key.md create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/sc07_ai_rate_limit_redis_key_test.go create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/bfe.conf create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/cluster_conf/gslb.data create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_rate_limit/mod_ai_rate_limit.conf create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_route/ai_route.data create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_route/mod_ai_route.conf create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_token_auth/mod_ai_token_auth.conf create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/server_data_conf/host_rule.data create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/server_data_conf/route_rule.data create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/server_data_conf/vip_rule.data create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/bfe_i_ca.crt create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/bfe_r_ca.crt create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_bfe_dev.crt create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_san_example.org.crt create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/certs/example.crt create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/certs/example.key create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/client_ca/example_ca.crt create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/client_ca/example_ca.key create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/server_cert_conf.data create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/session_ticket_key.data create mode 100644 tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/tls_rule_conf.data diff --git a/bfe_modules/mod_ai_rate_limit/data_load.go b/bfe_modules/mod_ai_rate_limit/data_load.go index 144ef4306..fee549959 100644 --- a/bfe_modules/mod_ai_rate_limit/data_load.go +++ b/bfe_modules/mod_ai_rate_limit/data_load.go @@ -39,6 +39,7 @@ type TPMRuleConfFile struct { MaxTokens int64 `json:"max_tokens"` StepMinutes int `json:"step_minutes"` Models []string `json:"models"` + RedisKey string `json:"redis_key"` } type RPMRuleConfFile struct { @@ -47,6 +48,7 @@ type RPMRuleConfFile struct { MaxRequests int64 `json:"max_requests"` Burst int64 `json:"burst"` Models []string `json:"models"` + RedisKey string `json:"redis_key"` } type LimitRulesConfFile struct { @@ -87,6 +89,7 @@ type TPMRuleConf struct { ReservedX float64 // 0 ReservedOff float64 // 0 Models []string + RedisKey string } type RPMRuleConf struct { @@ -95,6 +98,7 @@ type RPMRuleConf struct { MaxRequests int64 // RPMRuleConfFile.MaxRequests Burst int64 // RPMRuleConfFile.Burst Models []string + RedisKey string } type LimitRulesConf struct { @@ -146,6 +150,7 @@ func (f *TPMRuleConfFile) Convert() *TPMRuleConf { ReservedX: 0, ReservedOff: 0, Models: f.Models, + RedisKey: f.RedisKey, } } @@ -156,6 +161,7 @@ func (f *RPMRuleConfFile) Convert() *RPMRuleConf { MaxRequests: f.MaxRequests, Burst: f.Burst, Models: f.Models, + RedisKey: f.RedisKey, } } diff --git a/bfe_modules/mod_ai_rate_limit/data_load_test.go b/bfe_modules/mod_ai_rate_limit/data_load_test.go index 7eef7a5dc..5be94b5b1 100644 --- a/bfe_modules/mod_ai_rate_limit/data_load_test.go +++ b/bfe_modules/mod_ai_rate_limit/data_load_test.go @@ -98,6 +98,22 @@ func TestRPMRuleConfFileConvert(t *testing.T) { } } +func TestTPMRuleConfFileConvertWithRedisKey(t *testing.T) { + f := &TPMRuleConfFile{WindowMinutes: 5, MaxTokens: 1000, StepMinutes: 2, RedisKey: "RL_TPM_rlp-1_0"} + result := f.Convert() + if result.RedisKey != "RL_TPM_rlp-1_0" { + t.Errorf("RedisKey: expected RL_TPM_rlp-1_0, got %s", result.RedisKey) + } +} + +func TestRPMRuleConfFileConvertWithRedisKey(t *testing.T) { + f := &RPMRuleConfFile{WindowMinutes: 2, MaxRequests: 100, Burst: 10, RedisKey: "RL_RPM_rlp-1_0"} + result := f.Convert() + if result.RedisKey != "RL_RPM_rlp-1_0" { + t.Errorf("RedisKey: expected RL_RPM_rlp-1_0, got %s", result.RedisKey) + } +} + func TestLimitRulesConfFileConvert(t *testing.T) { maxConn := int64(50) f := &LimitRulesConfFile{ diff --git a/bfe_modules/mod_ai_rate_limit/policy_limiter.go b/bfe_modules/mod_ai_rate_limit/policy_limiter.go index b92b36dc9..6309724c3 100644 --- a/bfe_modules/mod_ai_rate_limit/policy_limiter.go +++ b/bfe_modules/mod_ai_rate_limit/policy_limiter.go @@ -106,6 +106,20 @@ func buildRpmInstId(rule *RPMRuleConf) string { return fmt.Sprintf("rpm_%d_%d_%d", rule.TimeWindow, rule.MaxRequests, rule.Burst) } +func buildTpmRedisKey(policyId string, rule *TPMRuleConf) string { + if rule.RedisKey != "" { + return buildRedisKey(policyId, rule.RedisKey) + } + return buildRedisKey(policyId, fmt.Sprintf("tpm_%s", buildTpmInstId(rule))) +} + +func buildRpmRedisKey(policyId string, rule *RPMRuleConf) string { + if rule.RedisKey != "" { + return buildRedisKey(policyId, rule.RedisKey) + } + return buildRedisKey(policyId, fmt.Sprintf("rpm_%s", buildRpmInstId(rule))) +} + func newTpmLimiterItem(limiter *limit_rate.TPMLimiter, tpmInstId string, rule *TPMRuleConf) *tpmLimiterItem { return &tpmLimiterItem{ limiter: limiter, @@ -135,7 +149,10 @@ func newPolicyLimiterSet(policyId string, policy *PolicyConf) *policyLimiterSet for _, rule := range policy.Rules.TPM { tpmInstId := buildTpmInstId(rule) - redisKey := buildRedisKey(policyId, fmt.Sprintf("tpm_%s", tpmInstId)) + if rule.RedisKey != "" { + tpmInstId = rule.RedisKey + } + redisKey := buildTpmRedisKey(policyId, rule) limiter := limit_rate.NewTPMLimiter( redisKey, rule.Threshold, @@ -148,7 +165,10 @@ func newPolicyLimiterSet(policyId string, policy *PolicyConf) *policyLimiterSet for _, rule := range policy.Rules.RPM { rpmInstId := buildRpmInstId(rule) - redisKey := buildRedisKey(policyId, fmt.Sprintf("rpm_%s", rpmInstId)) + if rule.RedisKey != "" { + rpmInstId = rule.RedisKey + } + redisKey := buildRpmRedisKey(policyId, rule) limiter := limit_rate.NewQPMLimiter( redisKey, rule.Burst, @@ -158,7 +178,7 @@ func newPolicyLimiterSet(policyId string, policy *PolicyConf) *policyLimiterSet ps.rpmLimiters = append(ps.rpmLimiters, newRpmLimiterItem(limiter, rpmInstId, rule)) } - if policy.Rules.MaxConcurrency != nil { + if policy.Rules.MaxConcurrency != nil && *policy.Rules.MaxConcurrency > 0 { redisKey := buildRedisKey(policyId, "con") ps.conLimiter = &conLimiterItem{ conLimiter: limit_rate.NewConcurrencyLimiter( diff --git a/bfe_modules/mod_ai_rate_limit/policy_limiter_test.go b/bfe_modules/mod_ai_rate_limit/policy_limiter_test.go index c1d13d74b..2863b4904 100644 --- a/bfe_modules/mod_ai_rate_limit/policy_limiter_test.go +++ b/bfe_modules/mod_ai_rate_limit/policy_limiter_test.go @@ -58,6 +58,38 @@ func TestBuildRpmInstIdWithoutName(t *testing.T) { } } +func TestBuildTpmRedisKeyWithRedisKey(t *testing.T) { + rule := &TPMRuleConf{Name: "abc", RedisKey: "RL_TPM_rlp-1_0", TimeWindow: 300, Threshold: 1000, BucketTimeWindow: 60, BucketThreshold: 1000} + want := "default_bfe_policy1_RL_TPM_rlp-1_0" + if got := buildTpmRedisKey("policy1", rule); got != want { + t.Errorf("expected %s, got %s", want, got) + } +} + +func TestBuildTpmRedisKeyFallbackToName(t *testing.T) { + rule := &TPMRuleConf{Name: "abc", TimeWindow: 300, Threshold: 1000, BucketTimeWindow: 60, BucketThreshold: 1000} + want := "default_bfe_policy1_tpm_abc" + if got := buildTpmRedisKey("policy1", rule); got != want { + t.Errorf("expected %s, got %s", want, got) + } +} + +func TestBuildRpmRedisKeyWithRedisKey(t *testing.T) { + rule := &RPMRuleConf{Name: "abc", RedisKey: "RL_RPM_rlp-1_0", TimeWindow: 120, MaxRequests: 100, Burst: 10} + want := "default_bfe_policy1_RL_RPM_rlp-1_0" + if got := buildRpmRedisKey("policy1", rule); got != want { + t.Errorf("expected %s, got %s", want, got) + } +} + +func TestBuildRpmRedisKeyFallbackToName(t *testing.T) { + rule := &RPMRuleConf{Name: "abc", TimeWindow: 120, MaxRequests: 100, Burst: 10} + want := "default_bfe_policy1_rpm_abc" + if got := buildRpmRedisKey("policy1", rule); got != want { + t.Errorf("expected %s, got %s", want, got) + } +} + func TestPredictTokenUsage(t *testing.T) { item := &tpmLimiterItem{ReservedX: 2.0, ReservedOff: 10.0} if got := item.predictTokenUsage(5); got != 20 { @@ -181,6 +213,50 @@ func TestNewPolicyLimiterSetEmptyRules(t *testing.T) { } } +func TestNewPolicyLimiterSetIgnoresZeroConcurrency(t *testing.T) { + zero := int64(0) + ps := newPolicyLimiterSet("rlp-zero-con", &PolicyConf{ + Name: "test", + Enabled: true, + Rules: &LimitRulesConf{ + MaxConcurrency: &zero, + RPM: []*RPMRuleConf{ + {Name: "rpm1", TimeWindow: 60, MaxRequests: 100, Burst: 1}, + }, + }, + }) + if ps.conLimiter != nil { + t.Error("expected no concurrency limiter when MaxConcurrency is 0") + } +} + +func TestNewPolicyLimiterSetUsesRedisKeyAsName(t *testing.T) { + ps := newPolicyLimiterSet("rlp-0001", &PolicyConf{ + Name: "test", + Enabled: true, + Rules: &LimitRulesConf{ + TPM: []*TPMRuleConf{ + {Name: "tpm1", RedisKey: "RL_TPM_rlp-0001_0", TimeWindow: 60, Threshold: 1000, BucketTimeWindow: 60, BucketThreshold: 1000}, + }, + RPM: []*RPMRuleConf{ + {Name: "rpm1", RedisKey: "RL_RPM_rlp-0001_0", TimeWindow: 60, MaxRequests: 100, Burst: 1}, + }, + }, + }) + if len(ps.tpmLimiters) != 1 { + t.Fatalf("expected 1 tpm limiter, got %d", len(ps.tpmLimiters)) + } + if ps.tpmLimiters[0].name != "RL_TPM_rlp-0001_0" { + t.Errorf("expected tpm limiter name RL_TPM_rlp-0001_0, got %s", ps.tpmLimiters[0].name) + } + if len(ps.rpmLimiters) != 1 { + t.Fatalf("expected 1 rpm limiter, got %d", len(ps.rpmLimiters)) + } + if ps.rpmLimiters[0].name != "RL_RPM_rlp-0001_0" { + t.Errorf("expected rpm limiter name RL_RPM_rlp-0001_0, got %s", ps.rpmLimiters[0].name) + } +} + func TestPolicyLimiterSetCheckNoRules(t *testing.T) { req := newTestRequest("AI_product", "ak-123", "gpt-4") meta := req.GetAiBasicInfo() diff --git a/conf/mod_ai_rate_limit/ai_rate_limit.data b/conf/mod_ai_rate_limit/ai_rate_limit.data index 8fa442619..693495b5f 100644 --- a/conf/mod_ai_rate_limit/ai_rate_limit.data +++ b/conf/mod_ai_rate_limit/ai_rate_limit.data @@ -16,12 +16,12 @@ "enabled": true, "rules": { "tpm": [ - {"name":"tpmabc1", "models": ["gpt-4"], "window_minutes": 1, "max_tokens": 10000, "step_minutes": 1}, - {"name":"tpmabc10", "models": ["*"], "window_minutes": 10, "max_tokens": 50000, "step_minutes": 1}, - {"name":"tpmabc60", "window_minutes": 60, "max_tokens": 200000, "step_minutes": 5} + {"name":"tpmabc1", "models": ["gpt-4"], "window_minutes": 1, "max_tokens": 10000, "step_minutes": 1, "redis_key": "RL_TPM_rlp-0001_0"}, + {"name":"tpmabc10", "models": ["*"], "window_minutes": 10, "max_tokens": 50000, "step_minutes": 1, "redis_key": "RL_TPM_rlp-0001_1"}, + {"name":"tpmabc60", "window_minutes": 60, "max_tokens": 200000, "step_minutes": 5, "redis_key": "RL_TPM_rlp-0001_2"} ], "rpm": [ - {"name":"rpmabc1", "window_minutes": 1, "max_requests": 100, "burst":1} + {"name":"rpmabc1", "window_minutes": 1, "max_requests": 100, "burst":1, "redis_key": "RL_RPM_rlp-0001_0"} ], "max_concurrency": 50 } @@ -31,11 +31,11 @@ "enabled": true, "rules": { "tpm": [ - {"name":"tpm1", "window_minutes": 1, "max_tokens": 200, "step_minutes": 1}, - {"name":"tpm2", "window_minutes": 10, "max_tokens": 10000, "step_minutes": 1} + {"name":"tpm1", "window_minutes": 1, "max_tokens": 200, "step_minutes": 1, "redis_key": "RL_TPM_rlp-0002_0"}, + {"name":"tpm2", "window_minutes": 10, "max_tokens": 10000, "step_minutes": 1, "redis_key": "RL_TPM_rlp-0002_1"} ], "rpm": [ - {"name":"rpm1", "window_minutes": 1, "max_requests": 30, "burst":1} + {"name":"rpm1", "window_minutes": 1, "max_requests": 30, "burst":1, "redis_key": "RL_RPM_rlp-0002_0"} ], "max_concurrency": 2 } diff --git a/docs/en_us/configuration/mod_ai_rate_limit/ai_rate_limit.data.md b/docs/en_us/configuration/mod_ai_rate_limit/ai_rate_limit.data.md index 26fabef68..5e261e4d8 100644 --- a/docs/en_us/configuration/mod_ai_rate_limit/ai_rate_limit.data.md +++ b/docs/en_us/configuration/mod_ai_rate_limit/ai_rate_limit.data.md @@ -29,12 +29,14 @@ | RateLimitPolicies{v}.rules.tpm[].max_tokens | Integer | Max token count within the window | Y | - | Positive integer | | RateLimitPolicies{v}.rules.tpm[].step_minutes | Integer | Step size | Y | Unit: minutes | Positive integer | | RateLimitPolicies{v}.rules.tpm[].models | Array | List of models the rule applies to | N | Applies to all models if not specified | Array of String | +| RateLimitPolicies{v}.rules.tpm[].redis_key | String | Stable Redis counter key | N | Generated by the control plane based on `(policy_id, rule_index)`; if not specified, BFE falls back to the original `name` logic | Non-empty string | | RateLimitPolicies{v}.rules.rpm | Array | List of RPM rate limit rules | N | - | Array of Object | | RateLimitPolicies{v}.rules.rpm[].name | String | Rule name | Y | - | Non-empty string | | RateLimitPolicies{v}.rules.rpm[].window_minutes | Integer | Time window | Y | Unit: minutes | Positive integer | | RateLimitPolicies{v}.rules.rpm[].max_requests | Integer | Max request count within the window | Y | - | Positive integer | | RateLimitPolicies{v}.rules.rpm[].burst | Integer | Burst request count | Y | - | Non-negative integer | | RateLimitPolicies{v}.rules.rpm[].models | Array | List of models the rule applies to | N | Applies to all models if not specified | Array of String | +| RateLimitPolicies{v}.rules.rpm[].redis_key | String | Stable Redis counter key | N | Generated by the control plane based on `(policy_id, rule_index)`; if not specified, BFE falls back to the original `name` logic | Non-empty string | | RateLimitPolicies{v}.rules.max_concurrency | Integer | Max concurrency | N | - | Non-negative integer | | ApikeyRateLimitPolicyBindings | Object | Binding from apikey to list of policy IDs | N | - | Key-value object | | ApikeyRateLimitPolicyBindings{k} | String | apikey | Y | Key of the binding | Non-empty string | @@ -61,12 +63,12 @@ "enabled": true, "rules": { "tpm": [ - {"name":"abc0", "window_minutes": 1, "max_tokens": 10000, "step_minutes": 1, "models": ["gpt-4", "gpt-4o"]}, - {"name":"abc1", "window_minutes": 10, "max_tokens": 50000, "step_minutes": 1, "models": ["gpt-3.5"]}, - {"name":"abc2", "window_minutes": 60, "max_tokens": 200000, "step_minutes": 5} + {"name":"abc0", "window_minutes": 1, "max_tokens": 10000, "step_minutes": 1, "models": ["gpt-4", "gpt-4o"], "redis_key": "RL_TPM_rlp-0001_0"}, + {"name":"abc1", "window_minutes": 10, "max_tokens": 50000, "step_minutes": 1, "models": ["gpt-3.5"], "redis_key": "RL_TPM_rlp-0001_1"}, + {"name":"abc2", "window_minutes": 60, "max_tokens": 200000, "step_minutes": 5, "redis_key": "RL_TPM_rlp-0001_2"} ], "rpm": [ - {"name":"abc0", "window_minutes": 1, "max_requests": 100, "burst":1, "models": ["gpt-4"]} + {"name":"abc0", "window_minutes": 1, "max_requests": 100, "burst":1, "models": ["gpt-4"], "redis_key": "RL_RPM_rlp-0001_0"} ], "max_concurrency": 50 } diff --git a/docs/zh_cn/SUMMARY.md b/docs/zh_cn/SUMMARY.md index 489d80938..df8dcc61b 100644 --- a/docs/zh_cn/SUMMARY.md +++ b/docs/zh_cn/SUMMARY.md @@ -123,6 +123,7 @@ * [mod_ai_route 对应 BFE 主程序修改方案](sys_design/mod_ai_route_bfe_changes.md) * [BFE 多 API-Key 支持](sys_design/multi_api_key.md) * [BFE AI 访问日志可观测字段设计](sys_design/ai_access_log_fields.md) + * [AI 限流 Redis 计数器 key 稳定性设计](sys_design/ai_rate_limit_redis_key.md) * [模块开发介绍](development/module/overview.md) * [BFE回调机制说明](development/module/bfe_callback.md) * [如何开发模块](development/module/how_to_write_module.md) diff --git a/docs/zh_cn/configuration/mod_ai_rate_limit/ai_rate_limit.data.md b/docs/zh_cn/configuration/mod_ai_rate_limit/ai_rate_limit.data.md index 9ce91e48c..b692cb346 100644 --- a/docs/zh_cn/configuration/mod_ai_rate_limit/ai_rate_limit.data.md +++ b/docs/zh_cn/configuration/mod_ai_rate_limit/ai_rate_limit.data.md @@ -29,12 +29,14 @@ | RateLimitPolicies{v}.rules.tpm[].max_tokens | Integer | 窗口内最大 Token 数 | Y | - | 正整数 | | RateLimitPolicies{v}.rules.tpm[].step_minutes | Integer | 步长 | Y | 单位:分钟 | 正整数 | | RateLimitPolicies{v}.rules.tpm[].models | Array | 该规则适用的模型列表 | N | 未指定时适用于所有模型 | 字符串数组 | +| RateLimitPolicies{v}.rules.tpm[].redis_key | String | 稳定的 Redis 计数器 key | N | 由控制面按 `(policy_id, rule_index)` 生成;未指定时 BFE 按原 `name` 逻辑兜底 | 非空字符串 | | RateLimitPolicies{v}.rules.rpm | Array | RPM(每分钟请求数)限流规则列表 | N | - | 元素为 Object | | RateLimitPolicies{v}.rules.rpm[].name | String | 规则名称 | Y | - | 非空字符串 | | RateLimitPolicies{v}.rules.rpm[].window_minutes | Integer | 时间窗口 | Y | 单位:分钟 | 正整数 | | RateLimitPolicies{v}.rules.rpm[].max_requests | Integer | 窗口内最大请求数 | Y | - | 正整数 | | RateLimitPolicies{v}.rules.rpm[].burst | Integer | 突发请求数 | Y | - | 非负整数 | | RateLimitPolicies{v}.rules.rpm[].models | Array | 该规则适用的模型列表 | N | 未指定时适用于所有模型 | 字符串数组 | +| RateLimitPolicies{v}.rules.rpm[].redis_key | String | 稳定的 Redis 计数器 key | N | 由控制面按 `(policy_id, rule_index)` 生成;未指定时 BFE 按原 `name` 逻辑兜底 | 非空字符串 | | RateLimitPolicies{v}.rules.max_concurrency | Integer | 最大并发数 | N | - | 非负整数 | | ApikeyRateLimitPolicyBindings | Object | apikey 到策略 ID 列表的绑定关系 | N | - | 键值对对象 | | ApikeyRateLimitPolicyBindings{k} | String | apikey | Y | 作为绑定关系的键 | 非空字符串 | @@ -61,12 +63,12 @@ "enabled": true, "rules": { "tpm": [ - {"name":"abc0", "window_minutes": 1, "max_tokens": 10000, "step_minutes": 1, "models": ["gpt-4", "gpt-4o"]}, - {"name":"abc1", "window_minutes": 10, "max_tokens": 50000, "step_minutes": 1, "models": ["gpt-3.5"]}, - {"name":"abc2", "window_minutes": 60, "max_tokens": 200000, "step_minutes": 5} + {"name":"abc0", "window_minutes": 1, "max_tokens": 10000, "step_minutes": 1, "models": ["gpt-4", "gpt-4o"], "redis_key": "RL_TPM_rlp-0001_0"}, + {"name":"abc1", "window_minutes": 10, "max_tokens": 50000, "step_minutes": 1, "models": ["gpt-3.5"], "redis_key": "RL_TPM_rlp-0001_1"}, + {"name":"abc2", "window_minutes": 60, "max_tokens": 200000, "step_minutes": 5, "redis_key": "RL_TPM_rlp-0001_2"} ], "rpm": [ - {"name":"abc0", "window_minutes": 1, "max_requests": 100, "burst":1, "models": ["gpt-4"]} + {"name":"abc0", "window_minutes": 1, "max_requests": 100, "burst":1, "models": ["gpt-4"], "redis_key": "RL_RPM_rlp-0001_0"} ], "max_concurrency": 50 } diff --git a/docs/zh_cn/sys_design/ai_rate_limit_redis_key.md b/docs/zh_cn/sys_design/ai_rate_limit_redis_key.md new file mode 100644 index 000000000..17103d5f6 --- /dev/null +++ b/docs/zh_cn/sys_design/ai_rate_limit_redis_key.md @@ -0,0 +1,235 @@ +# AI 限流 Redis 计数器 key 稳定性设计 + +## 1. 背景与问题 + +### 1.1 背景 + +`mod_ai_rate_limit` 负责 AI 请求的 TPM / RPM / 并发数限流。其中 RPM 计数器基于 Redis key 维护当前时间窗口内的请求次数。 + +### 1.2 问题 + +见 [ai-gateway-api/issues/84](https://github.com/rainway-ai-gateway/ai-gateway-api/issues/84)。 + +当前 BFE 的 RPM 计数器 Redis key 由「策略 ID + 规则名」构成: + +```go +// bfe/bfe_modules/mod_ai_rate_limit/policy_limiter.go + +func buildRpmInstId(rule *RPMRuleConf) string { + if rule.Name != "" { + return rule.Name + } + return fmt.Sprintf("rpm_%d_%d_%d", rule.TimeWindow, rule.MaxRequests, rule.Burst) +} + +redisKey := buildRedisKey(policyId, fmt.Sprintf("rpm_%s", rpmInstId)) +// => default_bfe__rpm_ +``` + +当管理面仅修改 RPM 规则名(其余字段不变)时,Redis key 发生变化,导致计数器从零开始。 + +### 1.3 为什么不能用 model 替代 name + +有人可能提议将 key 中的 `rule.Name` 替换为 `rule.Model`。但 `model` 同样是用户可编辑字段,修改 model 仍会改变 Redis key;且 `model` 可能为通配符 `*`,多个规则也可能指向同一 model,存在 key 冲突和语义混乱。因此 **计数器 key 不能依赖任何用户可编辑的业务字段(name / model)**。 + +## 2. 参考:quota_plan 的 Redis key 生成方式 + +`ai-gateway-api` 在导出 quota_plan 时,由控制面生成稳定的 `RedisKey` 并下发给 BFE: + +```go +// ai-gateway-api/model/imods/exporter.go + +func convertQuotaPlanToExport(qp *quota.QuotaPlanParam, id string, redisKeyID string) *QuotaPlan { + result := &QuotaPlan{ + Id: id, + RedisKey: fmt.Sprintf("QUOTA_%s", redisKeyID), + ... + } + ... +} +``` + +其中 `redisKeyID` 取的是配额归属方的稳定标识: +- API-Key 级配额:`redisKeyID = apiKey.Key` +- Entity 级配额:`redisKeyID = entity.EntityID` + +BFE 侧只消费现成的 `QuotaPlan.RedisKey`,不再根据计划名称/ID 自己拼装 key。这保证了:修改 quota_plan 名称、修改 model 等都不会重置 Redis 计数器。 + +RPM/TPM 限流应参照同一原则:**由控制面生成稳定的 Redis key 并下发,BFE 直接使用,key 不依赖用户可编辑字段**。 + +## 3. 目标 + +1. 消除「改名/改 model 导致 RPM/TPM 计数器重置」的问题; +2. 保持限流策略可共享(多个 API-Key 可绑定同一策略); +3. 向后兼容:未携带 `redis_key` 的旧配置仍能按原 `name` 逻辑工作; +4. 设计文档、配置文档、示例配置与代码实现保持一致。 + +## 4. 设计原则 + +- **稳定 key 由控制面生成**:`ai-gateway-api` 在导出限流策略时,基于不会随用户编辑而变的 `(policy_id, rule_index)` 为每条规则生成 `redis_key`; +- **BFE 直接使用下发 key**:BFE 读取规则中的 `redis_key` 字段构建 Redis key,不再根据 `name` 或 `model` 自己拼装; +- **兜底兼容**:旧配置或手工配置中若未指定 `redis_key`,BFE 保留原 `name` 逻辑,避免升级后所有计数器立即丢失; +- **规则结构性变更可接受**:删除/新增规则会改变后续规则下标,计数器会重置,这属于规则集合结构性变更,与单条规则改名不同。 + +## 5. 总体架构 + +``` +┌─────────────────────────────────────────────┐ +│ ai-gateway-api │ +│ 导出 rate-limit-policy │ +│ 为每条规则生成 redis_key = RL_{RPM|TPM}_rlp-_ +└──────────────────┬──────────────────────────┘ + ▼ +┌─────────────────────────────────────────────┐ +│ conf-agent 下发 ai_rate_limit.data │ +│ 包含 RateLimitPolicies[].rules.{tpm,rpm}[].redis_key +└──────────────────┬──────────────────────────┘ + ▼ +┌─────────────────────────────────────────────┐ +│ BFE mod_ai_rate_limit │ +│ 加载规则,优先使用 redis_key 构建 Redis key │ +│ 未指定时按原 name 逻辑兜底 │ +└─────────────────────────────────────────────┘ +``` + +## 6. 数据结构与字段变更 + +### 6.1 ai-gateway-api 导出结构 + +`ai-gateway-api/model/rate_limit_policy/rate_limit_policy.go`: + +```go +type ExportRPMConfig struct { + Name string `json:"name"` + Models []string `json:"models"` + WindowMinutes int `json:"window_minutes"` + MaxRequests int `json:"max_requests"` + Burst int `json:"burst"` + RedisKey string `json:"redis_key"` // 新增 +} + +type ExportTPMConfig struct { + Name string `json:"name"` + Models []string `json:"models"` + WindowMinutes int `json:"window_minutes"` + MaxTokens int `json:"max_tokens"` + StepMinutes int `json:"step_minutes"` + RedisKey string `json:"redis_key"` // 新增 +} +``` + +生成逻辑(`ai-gateway-api/model/rate_limit_policy/rate_limit_policy_manager.go`): + +```go +for idx, rpm := range policy.RpmConfigs { + models := []string{"*"} + if rpm.Model != "" && rpm.Model != "*" { + models = []string{rpm.Model} + } + exportPolicy.Rules.RPM = append(exportPolicy.Rules.RPM, ExportRPMConfig{ + Name: rpm.Name, + Models: models, + WindowMinutes: rpm.WindowMinutes, + MaxRequests: rpm.MaxRequests, + Burst: 1, + RedisKey: fmt.Sprintf("RL_RPM_rlp-%d_%d", policyID, idx), + }) +} +``` + +### 6.2 BFE 运行时结构 + +`bfe/bfe_modules/mod_ai_rate_limit/data_load.go`: + +```go +type RPMRuleConfFile struct { + Name string `json:"name"` + WindowMinutes int `json:"window_minutes"` + MaxRequests int64 `json:"max_requests"` + Burst int64 `json:"burst"` + Models []string `json:"models"` + RedisKey string `json:"redis_key"` // 新增 +} + +type RPMRuleConf struct { + Name string + TimeWindow int + MaxRequests int64 + Burst int64 + Models []string + RedisKey string // 新增 +} +``` + +### 6.3 BFE Redis key 构建 + +`bfe/bfe_modules/mod_ai_rate_limit/policy_limiter.go`: + +```go +func buildRpmRedisKey(policyId string, rule *RPMRuleConf) string { + if rule.RedisKey != "" { + return buildRedisKey(policyId, rule.RedisKey) + } + // 兼容旧配置 + return buildRedisKey(policyId, fmt.Sprintf("rpm_%s", buildRpmInstId(rule))) +} +``` + +## 7. 配置示例 + +```json +{ + "Version": "1.0", + "RateLimitPolicies": { + "rlp-0001": { + "name": "ratelimitX", + "enabled": true, + "rules": { + "tpm": [ + {"name":"tpm0", "window_minutes": 1, "max_tokens": 10000, "step_minutes": 1, "models": ["gpt-4"], "redis_key": "RL_TPM_rlp-0001_0"} + ], + "rpm": [ + {"name":"rpm0", "window_minutes": 1, "max_requests": 100, "burst": 1, "models": ["gpt-4"], "redis_key": "RL_RPM_rlp-0001_0"} + ], + "max_concurrency": 50 + } + } + }, + "ApikeyRateLimitPolicyBindings": { + "ak-2v8x9k3m7p": ["rlp-0001"] + } +} +``` + +## 8. 兼容性 + +| 场景 | 行为 | +|------|------| +| 新配置携带 `redis_key` | BFE 直接使用该 key 作为计数器 key | +| 旧配置未携带 `redis_key` | BFE 按原 `name` 逻辑兜底,计数器行为不变 | +| 改名(其余字段不变) | `redis_key` 不变,计数器不重置 | +| 改 model(其余字段不变) | `redis_key` 不变,计数器不重置 | +| 删除/新增规则 | 后续规则下标变化,对应 `redis_key` 变化,计数器重置(结构性变更,可接受) | + +## 9. 测试建议 + +1. **单元测试** + - BFE `buildRpmRedisKey` / `buildTpmRedisKey`:覆盖 `redis_key` 存在与缺失两种场景; + - BFE `Convert()`:验证 `redis_key` 从配置文件正确读取到运行时结构; + - ai-gateway-api 导出:验证 `redis_key` 按 `(policy_id, rule_index)` 生成。 + +2. **集成测试** + - 创建 API-Key 并绑定 RPM 策略,`max_requests=1`; + - 同一窗口内连发 2 个请求,验证第 2 个返回 429; + - PATCH 仅修改规则名,再发请求,验证仍返回 429; + - PATCH 仅修改 model,再发请求,验证仍返回 429。 + +## 10. 相关文件 + +- `ai-gateway-api/model/rate_limit_policy/rate_limit_policy.go` +- `ai-gateway-api/model/rate_limit_policy/rate_limit_policy_manager.go` +- `ai-gateway-api/model/imods/exporter.go`(quota_plan RedisKey 生成参考) +- `bfe/bfe_modules/mod_ai_rate_limit/data_load.go` +- `bfe/bfe_modules/mod_ai_rate_limit/policy_limiter.go` +- `bfe/docs/zh_cn/configuration/mod_ai_rate_limit/ai_rate_limit.data.md` +- `bfe/conf/mod_ai_rate_limit/ai_rate_limit.data` diff --git a/tests/integration/common/bfe_config_builder.go b/tests/integration/common/bfe_config_builder.go index 5489e2a75..e1db49adc 100644 --- a/tests/integration/common/bfe_config_builder.go +++ b/tests/integration/common/bfe_config_builder.go @@ -34,6 +34,46 @@ type TokenRuleData struct { Config map[string][]TokenRule } +// RateLimitRule is the JSON representation of a TPM/RPM rule in ai_rate_limit.data. +type RateLimitRule struct { + Name string `json:"name"` + WindowMinutes int `json:"window_minutes"` + MaxTokens int64 `json:"max_tokens,omitempty"` + MaxRequests int64 `json:"max_requests,omitempty"` + StepMinutes int `json:"step_minutes,omitempty"` + Burst int64 `json:"burst,omitempty"` + Models []string `json:"models,omitempty"` + RedisKey string `json:"redis_key,omitempty"` +} + +// RateLimitPolicy is the JSON representation of a rate limit policy in ai_rate_limit.data. +type RateLimitPolicy struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Rules struct { + TPM []RateLimitRule `json:"tpm,omitempty"` + RPM []RateLimitRule `json:"rpm,omitempty"` + MaxConcurrency *int64 `json:"max_concurrency,omitempty"` + } `json:"rules"` +} + +// RateLimitProductRule is the JSON representation of a product rule in ai_rate_limit.data. +type RateLimitProductRule struct { + Cond string `json:"cond"` + HitAction struct { + Cmd string `json:"cmd"` + Params []string `json:"params,omitempty"` + } `json:"hit_action"` +} + +// RateLimitPolicyData holds the content of mod_ai_rate_limit/ai_rate_limit.data. +type RateLimitPolicyData struct { + Version string `json:"Version"` + Config map[string][]RateLimitProductRule `json:"Config"` + RateLimitPolicies map[string]RateLimitPolicy `json:"RateLimitPolicies"` + ApikeyRateLimitPolicyBindings map[string][]string `json:"ApikeyRateLimitPolicyBindings"` +} + // QuotaPlan is the JSON representation of a quota plan. type QuotaPlan struct { Id string @@ -88,6 +128,8 @@ type BFEConfigBuilder struct { RedisAddr string // TokenRuleData optionally generates mod_ai_token_auth/token_rule.data. TokenRuleData *TokenRuleData + // RateLimitPolicyData optionally generates mod_ai_rate_limit/ai_rate_limit.data. + RateLimitPolicyData *RateLimitPolicyData } // Build prepares the BFE configuration directory. @@ -142,6 +184,12 @@ func (b *BFEConfigBuilder) Build() error { } } + if b.RateLimitPolicyData != nil { + if err := b.writeRateLimitPolicyData(); err != nil { + return fmt.Errorf("write ai_rate_limit.data failed: %w", err) + } + } + return nil } @@ -153,10 +201,13 @@ func (b *BFEConfigBuilder) setupRedisBns() error { return fmt.Errorf("parse redis addr %s failed: %w", b.RedisAddr, err) } - // rewrite mod_ai_token_auth.conf to use the fixed bns name - if err := b.rewriteModAITokenAuthBns(); err != nil { + // rewrite mod_ai_token_auth.conf and mod_ai_rate_limit.conf to use the fixed bns name + if err := b.rewriteModBns("mod_ai_token_auth"); err != nil { return fmt.Errorf("rewrite mod_ai_token_auth bns failed: %w", err) } + if err := b.rewriteModBns("mod_ai_rate_limit"); err != nil { + return fmt.Errorf("rewrite mod_ai_rate_limit bns failed: %w", err) + } // generate name_conf.data mapping bns name to redis addr nameConf := map[string]interface{}{ @@ -176,8 +227,8 @@ func (b *BFEConfigBuilder) setupRedisBns() error { return b.rewriteBFEConfNameConf() } -func (b *BFEConfigBuilder) rewriteModAITokenAuthBns() error { - path := filepath.Join(b.TargetConfDir, "mod_ai_token_auth", "mod_ai_token_auth.conf") +func (b *BFEConfigBuilder) rewriteModBns(modName string) error { + path := filepath.Join(b.TargetConfDir, modName, modName+".conf") data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { @@ -241,6 +292,11 @@ func (b *BFEConfigBuilder) writeTokenRuleData() error { return writeJSONFile(path, b.TokenRuleData) } +func (b *BFEConfigBuilder) writeRateLimitPolicyData() error { + path := filepath.Join(b.TargetConfDir, "mod_ai_rate_limit", "ai_rate_limit.data") + return writeJSONFile(path, b.RateLimitPolicyData) +} + func (b *BFEConfigBuilder) normalizeAIRouteData() error { path := filepath.Join(b.TargetConfDir, "mod_ai_route", "ai_route.data") data, err := os.ReadFile(path) diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/sc07_ai_rate_limit_redis_key_test.go b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/sc07_ai_rate_limit_redis_key_test.go new file mode 100644 index 000000000..860ccf414 --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/sc07_ai_rate_limit_redis_key_test.go @@ -0,0 +1,341 @@ +// Copyright (c) 2026 The BFE Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sc07 + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/bfenetworks/bfe/tests/integration/common" +) + +const ( + apiHost = "ratelimit.example.org" + apiPath = "/v1/chat/completions" + apiKey = "ak_ratelimit" + apiKeyId = "ratelimit_key_id" + + clusterRateLimit = "cluster_ratelimit" + + policyID = "rlp-0001" + rpmRedisKey = "RL_RPM_rlp-0001_0" +) + +var defaultBody = []byte(`{"model":"gpt-4"}`) +var usageResponse = `{"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}` + +// testEnv holds all resources for a single SC07 integration test. +type testEnv struct { + t *testing.T + processEnv *common.ProcessEnv + backend *common.MockBackend + redis *common.RedisServer + confDir string + logDir string + bfePort int + stopBFE func() +} + +func newTestEnv(t *testing.T) *testEnv { + e := &testEnv{t: t} + + e.backend = common.NewMockBackend(clusterRateLimit, http.StatusOK, usageResponse) + e.redis = common.NewRedisServer(t) + + e.processEnv = common.NewProcessEnv(t) + e.processEnv.Build() + + e.confDir = filepath.Join(e.processEnv.WorkDir(), "conf") + e.logDir = filepath.Join(e.processEnv.WorkDir(), "log") + + return e +} + +func (e *testEnv) startBFE(rateLimitData *common.RateLimitPolicyData) { + tokenRule := &common.TokenRuleData{ + Version: "1.0", + QuotaPlans: map[string][]common.QuotaPlan{ + "ai_product": { + { + Id: "unlimited_plan", + Unlimited: true, + PassNoQuota: false, + RedisKey: "quota:unlimited_plan", + ExpiredTime: -1, + Quota: 0, + Unit: "total_token", + }, + }, + }, + Tokens: map[string]map[string]common.TokenFile{ + "ai_product": { + apiKey: { + Key: apiKey, + KeyId: apiKeyId, + Enabled: true, + ExpiredTime: -1, + UnlimitedQuota: false, + QuotaPlans: []string{"unlimited_plan"}, + }, + }, + }, + Config: map[string][]common.TokenRule{ + "ai_product": { + { + Cond: "default_t()", + Action: common.ActionFile{Cmd: "CHECK_TOKEN"}, + }, + }, + }, + } + + backends := map[string]*common.MockBackend{ + clusterRateLimit: e.backend, + } + + builder := &common.BFEConfigBuilder{ + TemplateDir: "testdata", + TargetConfDir: e.confDir, + Backends: backends, + RedisAddr: e.redis.Addr(), + TokenRuleData: tokenRule, + RateLimitPolicyData: rateLimitData, + } + if err := builder.Build(); err != nil { + e.t.Fatalf("build bfe config failed: %v", err) + } + + e.bfePort, _, e.stopBFE = e.processEnv.StartBFE(e.confDir, e.logDir) +} + +func (e *testEnv) Close() { + if e.stopBFE != nil { + e.stopBFE() + } + if e.backend != nil { + e.backend.Close() + } + if e.redis != nil { + e.redis.Close() + } +} + +func (e *testEnv) logBFEException() { + data, err := os.ReadFile(filepath.Join(e.logDir, "exception.log")) + if err == nil && len(data) > 0 { + e.t.Logf("bfe exception log:\n%s", string(data)) + } + + logPath := filepath.Join(e.logDir, "bfe.log") + logData, err := os.ReadFile(logPath) + if err == nil && len(logData) > 0 { + lines := strings.Split(string(logData), "\n") + start := 0 + if len(lines) > 200 { + start = len(lines) - 200 + } + e.t.Logf("bfe log tail:\n%s", strings.Join(lines[start:], "\n")) + } +} + +func (e *testEnv) sendRequest() (*http.Response, string, error) { + return e.sendRequestWithBody(defaultBody) +} + +func (e *testEnv) sendRequestWithBody(body []byte) (*http.Response, string, error) { + url := fmt.Sprintf("http://127.0.0.1:%d%s", e.bfePort, apiPath) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, "", err + } + req.Host = apiHost + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + return resp, string(respBody), nil +} + +func rateLimitPolicy(ruleName, model, redisKey string) *common.RateLimitPolicyData { + return &common.RateLimitPolicyData{ + Version: "1.0", + Config: map[string][]common.RateLimitProductRule{ + "ai_product": { + { + Cond: "default_t()", + HitAction: struct { + Cmd string `json:"cmd"` + Params []string `json:"params,omitempty"` + }{Cmd: "FINISH"}, + }, + }, + }, + RateLimitPolicies: map[string]common.RateLimitPolicy{ + policyID: { + Name: "ratelimitX", + Enabled: true, + Rules: struct { + TPM []common.RateLimitRule `json:"tpm,omitempty"` + RPM []common.RateLimitRule `json:"rpm,omitempty"` + MaxConcurrency *int64 `json:"max_concurrency,omitempty"` + }{ + RPM: []common.RateLimitRule{ + { + Name: ruleName, + WindowMinutes: 1, + MaxRequests: 1, + Burst: 1, + Models: []string{model}, + RedisKey: redisKey, + }, + }, + }, + }, + }, + ApikeyRateLimitPolicyBindings: map[string][]string{ + apiKey: {policyID}, + }, + } +} + +// TestTC01 verifies that the RPM counter persists when the rule name changes +// but the redis_key stays the same. +func TestTC01_RPMCounterPersistsAcrossNameChange(t *testing.T) { + e := newTestEnv(t) + defer e.Close() + + // Start BFE with rule name "old-rule" and a stable redis_key. + e.startBFE(rateLimitPolicy("old-rule", "gpt-4", rpmRedisKey)) + + resp, body, err := e.sendRequest() + if err != nil { + t.Fatalf("send first request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected first request 200, got %d, body: %s", resp.StatusCode, body) + } + + resp, body, err = e.sendRequest() + if err != nil { + t.Fatalf("send second request failed: %v", err) + } + if resp.StatusCode != http.StatusTooManyRequests { + e.logBFEException() + t.Fatalf("expected second request 429, got %d, body: %s", resp.StatusCode, body) + } + + // Restart BFE with the same redis_key but a different rule name. + e.stopBFE() + e.stopBFE = nil + e.startBFE(rateLimitPolicy("new-rule", "gpt-4", rpmRedisKey)) + + resp, body, err = e.sendRequest() + if err != nil { + t.Fatalf("send third request after rename failed: %v", err) + } + if resp.StatusCode != http.StatusTooManyRequests { + e.logBFEException() + t.Fatalf("expected third request still 429 after rename, got %d, body: %s", resp.StatusCode, body) + } +} + +// TestTC02 verifies that the RPM counter persists when the rule model changes +// but the redis_key stays the same. The request body model is updated to match +// the new rule model so the rule is still evaluated. +func TestTC02_RPMCounterPersistsAcrossModelChange(t *testing.T) { + e := newTestEnv(t) + defer e.Close() + + // Start BFE with model "gpt-4" and a stable redis_key. + e.startBFE(rateLimitPolicy("rule-1", "gpt-4", rpmRedisKey)) + + resp, body, err := e.sendRequest() + if err != nil { + t.Fatalf("send first request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected first request 200, got %d, body: %s", resp.StatusCode, body) + } + + resp, body, err = e.sendRequest() + if err != nil { + t.Fatalf("send second request failed: %v", err) + } + if resp.StatusCode != http.StatusTooManyRequests { + e.logBFEException() + t.Fatalf("expected second request 429, got %d, body: %s", resp.StatusCode, body) + } + + // Restart BFE with the same redis_key but a different model. + e.stopBFE() + e.stopBFE = nil + e.startBFE(rateLimitPolicy("rule-1", "gpt-3.5", rpmRedisKey)) + + // Send a request matching the new model; the counter should still persist. + resp, body, err = e.sendRequestWithBody([]byte(`{"model":"gpt-3.5"}`)) + if err != nil { + t.Fatalf("send third request after model change failed: %v", err) + } + if resp.StatusCode != http.StatusTooManyRequests { + e.logBFEException() + t.Fatalf("expected third request still 429 after model change, got %d, body: %s", resp.StatusCode, body) + } +} + +// TestTC03 verifies backward compatibility: when redis_key is absent, +// BFE falls back to the original name-based Redis key. +func TestTC03_RPMFallbackToNameWhenRedisKeyMissing(t *testing.T) { + e := newTestEnv(t) + defer e.Close() + + // Start BFE without redis_key; BFE should build key from rule name. + e.startBFE(rateLimitPolicy("name-based-rule", "gpt-4", "")) + + resp, body, err := e.sendRequest() + if err != nil { + t.Fatalf("send first request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected first request 200, got %d, body: %s", resp.StatusCode, body) + } + + resp, body, err = e.sendRequest() + if err != nil { + t.Fatalf("send second request failed: %v", err) + } + if resp.StatusCode != http.StatusTooManyRequests { + e.logBFEException() + t.Fatalf("expected second request 429, got %d, body: %s", resp.StatusCode, body) + } +} diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/bfe.conf b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/bfe.conf new file mode 100644 index 000000000..27c5fecd7 --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/bfe.conf @@ -0,0 +1,38 @@ +[server] +httpPort = 18080 +httpsPort = 18443 +monitorPort = 18081 +httpAddr = "127.0.0.1" +httpsAddr = "127.0.0.1" +monitorAddr = "127.0.0.1" +MonitorEnabled = true +maxCpus = 1 + +TlsHandshakeTimeout = 30 +ClientReadTimeout = 5 +ClientWriteTimeout = 5 +KeepAliveEnabled = true +GracefulShutdownTimeout = 10 + +EnableAiGateway = true + +accessibleBodySize = 4194304 + +# max total bytes of all active bytes_body buffers (0 means unlimited) +totalBodyBufferSize = 0 + +Modules = mod_ai_token_auth +Modules = mod_ai_route +Modules = mod_ai_rate_limit + +hostRuleConf = server_data_conf/host_rule.data +routeRuleConf = server_data_conf/route_rule.data +vipRuleConf = server_data_conf/vip_rule.data + +clusterTableConf = cluster_conf/cluster_table.data +gslbConf = cluster_conf/gslb.data +clusterConf = cluster_conf/cluster_conf.data +NameConf = + +maxHeaderUriBytes = 8096 +maxHeaderBytes = 8096 diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/cluster_conf/gslb.data b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/cluster_conf/gslb.data new file mode 100644 index 000000000..793066ecf --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/cluster_conf/gslb.data @@ -0,0 +1,10 @@ +{ + "clusters": { + "cluster_ratelimit": { + "GSLB_BLACKHOLE": 0, + "sub_sub": 100 + } + }, + "hostname": "gslb-test", + "ts": "20260720150000" +} diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_rate_limit/mod_ai_rate_limit.conf b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_rate_limit/mod_ai_rate_limit.conf new file mode 100644 index 000000000..5502c5361 --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_rate_limit/mod_ai_rate_limit.conf @@ -0,0 +1,14 @@ +[basic] +ProductRulePath = mod_ai_rate_limit/ai_rate_limit.data +IsRejectOnRedisError = true + +[redis] +Bns = 127.0.0.1:6379 +ConnectTimeout = 1000 +ReadTimeout = 1000 +WriteTimeout = 1000 +MaxIdle = 10 +MaxActive = 20 + +[log] +OpenDebug = true diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_route/ai_route.data b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_route/ai_route.data new file mode 100644 index 000000000..6be6da1cd --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_route/ai_route.data @@ -0,0 +1,28 @@ +{ + "Version": "20260720150000", + "route_rules": { + "apikey_ak_ratelimit": { + "type": "apikey", + "owner": "ak_ratelimit", + "rules": [ + { + "name": "ratelimit-default", + "Cond": "default_t()", + "targets": [ + { + "ClusterName": "cluster_ratelimit", + "Model": "", + "Weight": 100 + } + ], + "fallbacks": [] + } + ] + } + }, + "ApikeyRouteTableBindings": { + "ak_ratelimit": [ + "apikey_ak_ratelimit" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_route/mod_ai_route.conf b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_route/mod_ai_route.conf new file mode 100644 index 000000000..f250013bb --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_route/mod_ai_route.conf @@ -0,0 +1,5 @@ +[basic] +RouteRulePath = mod_ai_route/ai_route.data + +[log] +OpenDebug = true diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_token_auth/mod_ai_token_auth.conf b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_token_auth/mod_ai_token_auth.conf new file mode 100644 index 000000000..966b473d0 --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/mod_ai_token_auth/mod_ai_token_auth.conf @@ -0,0 +1,13 @@ +[basic] +ProductRulePath = mod_ai_token_auth/token_rule.data + +[redis] +Bns = 127.0.0.1:6379 +ConnectTimeout = 1000 +ReadTimeout = 1000 +WriteTimeout = 1000 +MaxIdle = 10 +MaxActive = 20 + +[log] +OpenDebug = true diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/server_data_conf/host_rule.data b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/server_data_conf/host_rule.data new file mode 100644 index 000000000..adae5683e --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/server_data_conf/host_rule.data @@ -0,0 +1,13 @@ +{ + "Version": "20260720150000", + "Hosts": { + "ai_product": [ + "ratelimit.example.org" + ] + }, + "HostTags": { + "ai_product": [ + "ai_product" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/server_data_conf/route_rule.data b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/server_data_conf/route_rule.data new file mode 100644 index 000000000..4c89af623 --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/server_data_conf/route_rule.data @@ -0,0 +1,12 @@ +{ + "Version": "20260720150000", + "BasicRule": { + "ai_product": [ + { + "Hostname": ["*"], + "Path": ["*"], + "ClusterName": "cluster_ratelimit" + } + ] + } +} diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/server_data_conf/vip_rule.data b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/server_data_conf/vip_rule.data new file mode 100644 index 000000000..6fe22f03a --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/server_data_conf/vip_rule.data @@ -0,0 +1,8 @@ +{ + "Version": "20260720150000", + "Vips": { + "ai_vip": [ + "127.0.0.1" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/bfe_i_ca.crt b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/bfe_i_ca.crt new file mode 100644 index 000000000..f1e78f73c --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/bfe_i_ca.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDwDCCAqigAwIBAgIBCzANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzExMDMx +NDAxNDZaFw0zNzA3MTIxNDAxNDZaMIGQMQswCQYDVQQGEwJjbjEQMA4GA1UECAwH +YmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsMC3lpbmdmZWkt +ZGV2MRgwFgYDVQQDDA95aW5nZmVpLWRldi1pY2ExKTAnBgkqhkiG9w0BCQEWGmxp +YW5nY2h1YW5AeWYtbmV0d29ya3MuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA6eFuWgoknixrRO9NCX4jAKyLtcAOWVJqVN2yX7CxjZjLyPurjvTZ +W73NYUbrPAN4AB5gY6UAPzuiEOSopVVIZ0OschK0cJldu9vZ0mZObBOsovuFcLQq +dgNSJ5slJSk7tCgD2EnCB3GYPG4D+uIKYd0c49wzTWWv4bjDwpgnf0LQbFpy7GhN +7D59zFH4qgOK/IQ5vaTMGyvIvtWR5/1Gvc9MLpGopTgi0DiNLed4UwDYrod5kysl +q3UcB5puONHQISOVoD3uRxo7wdsmVsHUfW7YfAWkhi6ec8mx9fy8IyE6f7GlXnuV +ysNccwqyEotL5bOXPJCqwUCL1v+iKTMPWwIDAQABo1AwTjAdBgNVHQ4EFgQUCjvC +vDVr8QXh9/hMI5xBgNvoSFgwHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/oqHdW +1D0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAhIggK/6JK4N7+HZW +VoTemwRpilugZZyKrcVAHbiiwUfXVQVuI64vc+yHWMSFRD1mykHkKBFzxEoDabMl +ASBtUJNt4b4zEL9V7k295vmAOp2IdLUQxlgeKWqABm4DGX96tGR9nKQTcn1ZeAxx +NyQFV1aj+dnNcF1iFFNF6t0bRrOEZ/aRSiu1bWp3Dj1JYTjXbyyplh3Ktb8lv4lt +EmvCXjo/l4TgQC9233kcTkXcq1swppzkkXfhB0NVuf9DE3C2xAWX0b2FiIEqnAhl +Bx0Cn3RrX7PZ6qrdRL6oBKvGJV6DP8BlF4LXiuD1VN/waQFJha57KQ78ZYYIXxQg +WMjWgA== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/bfe_r_ca.crt b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/bfe_r_ca.crt new file mode 100644 index 000000000..2a6db1e49 --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/bfe_r_ca.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDkzCCAnugAwIBAgIBCjANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzExMDMx +MzQ5NTVaFw0zNzA3MTIxMzQ5NTVaMGQxCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEUMBIGA1UECwwLeWluZ2ZlaS1k +ZXYxFzAVBgNVBAMMDnlpbmdmZWktZGV2LWNhMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAvNA3HrsMjBcXrMIIhGVWsurIA1F9jxKeA7dh06H00Vt4inVV +SUvNFrTqgPRhLkAhGRMPxrjVRgJ5bbFqqXIuPIpUFBhUsWXIDH+oVXQl9jsxAXaG +gZ0lTO/uYR9qyrS1rj9nyNPwRf59Al/VlsQL71cNQ/T/agJ4PfvPfULTPLOsqclJ +hj0IgXmDj464dqcdG3ZdfXpfhNF6ab+8YjpwafTRmY+LoV8qjUwsYeJMcW4N8pxJ +8F2ktZj9J6uWepNGj+87ZeXg9XquzC62ASIFzPjoE1WN//Q518EqizxhuLGBDpK3 +6sEYUK4kHYUL4gZKFPlKTPXIl0ZsJIM5PsBMKwIDAQABo1AwTjAdBgNVHQ4EFgQU +DZloT8VhVbysD819oG/oqHdW1D0wHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/o +qHdW1D0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEALgB+IcN3UD4T ++4g8jaOyOiSUpUVdYqW+3go5DppqnvlGNq99N2cXKqssVPa/T9TikEcBEicFa8zU +bwlx6TEte+MkWfWdQxFR1EuI1FgKr3ps6ZBRr7MPpnYmI7K9aK372K9n7WrQhbmP +s7ult8bWB1/t6o3R7B9ChNkWT+7DPD4+FvB1GMJSGPno7cdnvDkevBOuC2DnQl3M ++ADFAge1Lo8wKBy6gYkNFd2BfarHGvRC5Qmmrme+RIpWZnvux1+lfXIInnfSTJRM +uAo/ePkoNsM3qQll6uEdhDxOMx8Pq94bCkM3DtI3ObuxWXjKCgUm/n9yetUvPj4U +iYhRUqHpUg== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_bfe_dev.crt b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_bfe_dev.crt new file mode 100644 index 000000000..2164fb602 --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_bfe_dev.crt @@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 18 (0x12) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=yingfei-dev-ca + Validity + Not Before: Jan 31 02:55:32 2024 GMT + Not After : Jan 28 02:55:32 2034 GMT + Subject: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=bfe-dev/emailAddress=dev@example.org + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:b2:7f:c1:0c:cd:49:43:d9:99:78:15:4c:5a:52: + a9:a7:bf:d5:eb:92:71:43:e1:37:e5:29:1b:68:f4: + 6f:4c:ea:fa:a4:8a:7c:29:01:2a:fa:7c:81:5b:c8: + eb:a1:40:94:b7:2b:82:e2:00:08:36:84:f0:b7:d2: + 5a:1e:56:97:aa:36:ff:4d:07:49:2d:fe:25:3a:f9: + e9:f1:ad:4e:6e:21:97:a9:f9:a2:a5:ac:82:23:0a: + d2:e0:97:cd:2f:14:b1:f0:8a:a8:e6:c5:97:45:94: + f8:8e:3f:96:66:5b:0b:8c:7c:07:61:18:44:92:f7: + 23:5a:c2:4b:88:58:59:5d:ca:5c:0d:6e:dd:ff:18: + 59:65:df:95:99:e3:3a:36:48:1f:3f:3a:e6:ce:85: + f3:0b:04:5e:92:ed:6f:8e:74:92:e4:37:46:da:5f: + 17:62:9c:82:40:06:fb:29:f8:55:f2:ba:23:75:ca: + 64:c0:45:03:12:bd:f5:17:15:7e:47:d5:bd:30:f2: + 99:ca:6b:e3:07:b0:ae:44:89:1e:10:26:ea:75:df: + 6f:07:b6:47:76:54:47:4f:6c:f6:68:fe:a8:cf:22: + 20:73:e8:19:55:8a:fe:f5:78:e8:51:88:52:80:1f: + 79:d4:c5:ae:8f:d2:2b:f6:41:01:42:01:cf:98:c2: + c9:25 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + EE:E8:68:42:DF:B1:F0:EF:6F:47:51:BD:D4:94:60:1F:05:85:A1:03 + X509v3 Authority Key Identifier: + keyid:0D:99:68:4F:C5:61:55:BC:AC:0F:CD:7D:A0:6F:E8:A8:77:56:D4:3D + + X509v3 Extended Key Usage: + TLS Web Client Authentication + Signature Algorithm: sha256WithRSAEncryption + a9:a6:26:8e:42:61:15:22:ee:fc:b5:e1:e4:6b:dd:ac:f5:15: + 11:39:10:9a:ca:6f:85:fd:cb:90:1c:b2:4f:fe:29:de:b0:e7: + 73:e2:f7:5e:63:8c:7f:c1:7e:75:2e:c9:9d:e9:c2:45:75:f3: + 27:ba:82:94:de:7f:6c:87:0c:5c:71:af:0f:14:00:68:35:f7: + 5a:4a:ff:f5:ef:35:dd:50:72:76:f0:6f:b6:7b:42:33:07:b4: + 24:44:0a:fd:9d:61:9e:44:e8:88:0f:02:76:c6:90:3f:9d:1b: + d8:3b:64:25:2a:a3:39:78:38:bd:20:89:4a:9c:bd:68:38:18: + 4c:cb:20:3a:9b:5b:5f:58:52:86:73:de:85:fe:d6:a1:c6:a7: + 86:b0:96:4b:fa:28:04:ad:5d:85:e8:a1:fc:ca:0f:3c:be:5c: + 90:7e:3e:84:ae:67:ee:9a:72:71:3c:b2:80:45:82:fc:7e:58: + 74:99:42:c5:c3:8a:4a:eb:e1:8b:d5:84:ce:25:aa:a1:75:79: + 94:66:ae:ee:df:30:15:0b:b5:c5:b1:2c:d5:0a:54:78:b6:2e: + 67:29:81:41:f6:16:49:31:96:e7:41:e1:99:6b:27:57:bb:7d: + 76:eb:e4:d5:59:aa:a2:5c:bd:1c:18:2a:fa:9d:28:1a:0b:b6: + bf:7d:58:1a +-----BEGIN CERTIFICATE----- +MIID7jCCAtagAwIBAgIBEjANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yNDAxMzEw +MjU1MzJaFw0zNDAxMjgwMjU1MzJaMH0xCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEUMBIGA1UECwwLeWluZ2ZlaS1k +ZXYxEDAOBgNVBAMMB2JmZS1kZXYxHjAcBgkqhkiG9w0BCQEWD2RldkBleGFtcGxl +Lm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ/wQzNSUPZmXgV +TFpSqae/1euScUPhN+UpG2j0b0zq+qSKfCkBKvp8gVvI66FAlLcrguIACDaE8LfS +Wh5Wl6o2/00HSS3+JTr56fGtTm4hl6n5oqWsgiMK0uCXzS8UsfCKqObFl0WU+I4/ +lmZbC4x8B2EYRJL3I1rCS4hYWV3KXA1u3f8YWWXflZnjOjZIHz865s6F8wsEXpLt +b450kuQ3RtpfF2KcgkAG+yn4VfK6I3XKZMBFAxK99RcVfkfVvTDymcpr4wewrkSJ +HhAm6nXfbwe2R3ZUR09s9mj+qM8iIHPoGVWK/vV46FGIUoAfedTFro/SK/ZBAUIB +z5jCySUCAwEAAaOBkTCBjjAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVu +U1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU7uhoQt+x8O9vR1G9 +1JRgHwWFoQMwHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/oqHdW1D0wEwYDVR0l +BAwwCgYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADggEBAKmmJo5CYRUi7vy14eRr +3az1FRE5EJrKb4X9y5Acsk/+Kd6w53Pi915jjH/BfnUuyZ3pwkV18ye6gpTef2yH +DFxxrw8UAGg191pK//XvNd1Qcnbwb7Z7QjMHtCRECv2dYZ5E6IgPAnbGkD+dG9g7 +ZCUqozl4OL0giUqcvWg4GEzLIDqbW19YUoZz3oX+1qHGp4awlkv6KAStXYXoofzK +Dzy+XJB+PoSuZ+6acnE8soBFgvx+WHSZQsXDikrr4YvVhM4lqqF1eZRmru7fMBUL +tcWxLNUKVHi2LmcpgUH2FkkxludB4ZlrJ1e7fXbr5NVZqqJcvRwYKvqdKBoLtr99 +WBo= +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem new file mode 100644 index 000000000..764aea3ad --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAsn/BDM1JQ9mZeBVMWlKpp7/V65JxQ+E35SkbaPRvTOr6pIp8 +KQEq+nyBW8jroUCUtyuC4gAINoTwt9JaHlaXqjb/TQdJLf4lOvnp8a1ObiGXqfmi +payCIwrS4JfNLxSx8Iqo5sWXRZT4jj+WZlsLjHwHYRhEkvcjWsJLiFhZXcpcDW7d +/xhZZd+VmeM6NkgfPzrmzoXzCwReku1vjnSS5DdG2l8XYpyCQAb7KfhV8rojdcpk +wEUDEr31FxV+R9W9MPKZymvjB7CuRIkeECbqdd9vB7ZHdlRHT2z2aP6ozyIgc+gZ +VYr+9XjoUYhSgB951MWuj9Ir9kEBQgHPmMLJJQIDAQABAoIBAFAKHzupVb/58+o3 +yqv5wx94Uuk2GlnwxIqaezL94GaiO0/K1U/huS7m426P0rDU75qPBTpn/0bLJ9GV +nllaRNnLnYEh0juwaWtfovp+1ttlbseGK9uUVip2cQbKqvQAmKWe14vbcDCAU1Ad +zUgKbUxKVVjBdAZekVjiJNJ3o2L9WPhf/uQo7A1XAJh2DajlTbgvrDM73W+47QuU +X4OHU0FMio6bxupu3OWl1bMrnKhuC4qczZWf2nOpcVQa89rtopuP4ENLJuWkbeGk +YQpNilEclnAa/Noumt/j/6GKC1EEHFsH2CNRRIazcZrsFhkSKc4pn1Y/WI3vj8kZ ++RYnJsUCgYEA6gr0x8suwRTAmVpoPk4XyP1x+eInG7onV5RjgsUtgruYd9naxRHg +2p8PHcv32pDs51Fa+4RldyMd/jec1SscRF5/+VOP9qeoRnaDqUu+uASgEO3OBxbP +JcWovyxRHIQxbYCQtqIr9bdzXw55MBLZou/sBUVTAkrIyPVyjWqZlV8CgYEAwz7L +YyYN615TsrzZKURxMjj94Nmob/NldSLRXaR3Ax7/ABtEOA685cwQxq7ONdkJTMIA +uR8u2GHZSzGiWnehuF6Zp7Xs71a57eFbs3ueZvvEba4Dff7hl7Y4tTlwrKndKjvP +J/5a2Ol8siQcRWAXHOdzggEHMSZ/sB4hWswly/sCgYEAhLRBpyemEwTZUBrbELjm +86gBgFajJi2fMSGKaxOygnYsNYjpauSAQnX99D87Aks6iM6wb/zaK3tV/lc6LgSL +uph6p7yh3JGj8JAyh0PTmDPHLtIoCAz+18QDsqJGO40ZGaXUaDn8Aw9J85QZUxDd +Jm4zvalZL+uHfarukRDolLECgYBUiupS4nWAh3XCnZeDEQna72avaFBROZmjIRJ7 +c+28wj009JmTlH4jGzvgbG0KUBKA1Div8Fq+g5AtyS498jNqvDvYrSQNdwZHhR/K +Fis++KHTxFfqxOU2Zkcj4d1yRpNn6EIJVVBNQL0n/g7n03XupCIWFw/gLoV343QZ +9vAe5QKBgA8ml59z1w3eUooc0yGfLhihXqCmM3IU006bFbODA30fBU4QKrHO8+Yx +Xbz9bi/1QLagLG6FzYQAkOjBlEBt5XLayYvwSb8xvWsm5A3vzTAbMFDOsDPEmRoH +dWtQccJcygOuK+PZtoZnNoJciNO5c9dZWD3xtIiURmX/kVtWrf6N +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_san_example.org.crt b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_san_example.org.crt new file mode 100644 index 000000000..78f3892b4 --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_san_example.org.crt @@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 15 (0xf) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=yingfei-dev-ca + Validity + Not Before: Dec 7 09:57:42 2023 GMT + Not After : Dec 4 09:57:42 2033 GMT + Subject: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei, CN=dev-test + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:d6:47:b0:17:69:91:4c:d0:66:c9:25:2e:38:f2: + 84:27:e2:7c:38:cc:04:b9:0c:8e:3d:cc:ef:4b:c5: + 35:2b:c1:82:d8:41:fc:25:c8:24:f2:e0:25:aa:f9: + 76:3c:e2:b2:50:fb:2d:ec:4e:16:d4:c1:da:1e:e3: + 51:9f:9d:c9:72:f9:cb:cc:14:9c:c1:82:c9:76:1f: + 98:dd:0e:b9:8a:20:d6:ab:f2:a6:f9:2f:23:81:f3: + af:e4:47:0c:55:95:94:de:ed:f6:a5:24:ee:32:e7: + 6b:79:e1:42:6f:a4:07:b2:95:1d:f5:a9:8e:60:42: + 70:42:bd:e2:30:18:68:74:52:32:98:a9:81:da:d8: + c6:6f:5e:1d:ce:79:b6:f3:ec:4f:ed:7d:22:57:d2: + 14:d0:fb:f2:50:d4:80:3b:89:ed:77:fd:45:6c:e6: + 52:6b:0a:52:71:ac:59:c8:d5:25:f5:40:03:fb:51: + b0:11:a7:00:79:d9:d8:4f:00:43:96:68:44:29:41: + dc:d2:cc:91:c8:61:95:41:4d:0e:66:5a:b5:15:67: + 3e:8a:6f:29:df:1c:8a:6f:ee:9e:97:9c:9e:69:71: + d3:34:52:75:e9:ea:e7:51:77:23:98:46:ca:47:a2: + d3:d3:97:03:41:4b:e3:33:11:72:2d:af:bf:2b:3e: + b3:51 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + 5A:27:32:9D:E7:36:24:A3:C1:DC:2F:95:80:C5:CF:0C:85:E8:E6:AF + X509v3 Authority Key Identifier: + keyid:0D:99:68:4F:C5:61:55:BC:AC:0F:CD:7D:A0:6F:E8:A8:77:56:D4:3D + + X509v3 Subject Alternative Name: + DNS:example.org, DNS:www.example.org, DNS:example.com, DNS:*.example.com, IP Address:127.0.0.1, IP Address:192.168.0.100 + Signature Algorithm: sha256WithRSAEncryption + 1e:e8:e8:8a:ad:a8:0e:fc:c9:82:00:a1:ab:30:3c:a5:b9:dc: + d6:fb:86:ad:30:52:7f:61:be:90:a6:b8:56:bb:f1:0b:e6:39: + 38:65:09:6b:da:83:f7:65:ff:c4:21:de:b4:9e:8b:bd:1e:1c: + d1:d5:94:b8:18:79:f2:d0:06:51:39:67:13:40:3b:73:5b:cb: + ea:de:c1:19:76:f8:7b:0f:15:51:61:49:fb:98:f7:ea:4f:fc: + c2:fb:a7:f4:3c:48:64:14:79:b5:78:5b:20:10:b5:7a:2d:4c: + 04:51:60:ec:20:10:19:26:5f:e2:fd:32:59:67:e9:3f:48:8d: + f5:52:12:01:81:2c:c0:e5:72:cd:7d:0a:eb:7a:05:df:a0:77: + b9:ba:9a:7d:d1:4b:6a:44:e4:2d:98:af:bd:77:2b:f5:ef:26: + 4b:75:b3:97:d0:3a:bc:07:21:ef:71:92:30:fe:a2:79:e5:56: + d7:7e:c2:f3:57:ab:d7:de:fc:97:ed:20:0c:9a:cb:c5:5d:00: + 3b:61:29:e8:00:d4:39:e0:f2:4e:a4:03:c2:12:52:ff:e7:78: + f9:f7:c0:12:dc:36:a4:05:a2:f0:6b:47:e2:21:3d:a2:e1:a1: + 91:c7:ac:8f:b8:ae:58:65:e0:2b:57:80:eb:77:2d:48:ef:e6: + fb:b9:e1:20 +-----BEGIN CERTIFICATE----- +MIIEBzCCAu+gAwIBAgIBDzANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzEyMDcw +OTU3NDJaFw0zMzEyMDQwOTU3NDJaMFoxCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEQMA4GA1UECwwHeWluZ2ZlaTER +MA8GA1UEAwwIZGV2LXRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQDWR7AXaZFM0GbJJS448oQn4nw4zAS5DI49zO9LxTUrwYLYQfwlyCTy4CWq+XY8 +4rJQ+y3sThbUwdoe41Gfncly+cvMFJzBgsl2H5jdDrmKINar8qb5LyOB86/kRwxV +lZTe7falJO4y52t54UJvpAeylR31qY5gQnBCveIwGGh0UjKYqYHa2MZvXh3Oebbz +7E/tfSJX0hTQ+/JQ1IA7ie13/UVs5lJrClJxrFnI1SX1QAP7UbARpwB52dhPAEOW +aEQpQdzSzJHIYZVBTQ5mWrUVZz6KbynfHIpv7p6XnJ5pcdM0UnXp6udRdyOYRspH +otPTlwNBS+MzEXItr78rPrNRAgMBAAGjgc0wgcowCQYDVR0TBAIwADAsBglghkgB +hvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYDVR0OBBYE +FFonMp3nNiSjwdwvlYDFzwyF6OavMB8GA1UdIwQYMBaAFA2ZaE/FYVW8rA/NfaBv +6Kh3VtQ9ME8GA1UdEQRIMEaCC2V4YW1wbGUub3Jngg93d3cuZXhhbXBsZS5vcmeC +C2V4YW1wbGUuY29tgg0qLmV4YW1wbGUuY29thwR/AAABhwTAqABkMA0GCSqGSIb3 +DQEBCwUAA4IBAQAe6OiKragO/MmCAKGrMDyludzW+4atMFJ/Yb6QprhWu/EL5jk4 +ZQlr2oP3Zf/EId60nou9HhzR1ZS4GHny0AZROWcTQDtzW8vq3sEZdvh7DxVRYUn7 +mPfqT/zC+6f0PEhkFHm1eFsgELV6LUwEUWDsIBAZJl/i/TJZZ+k/SI31UhIBgSzA +5XLNfQrregXfoHe5upp90UtqROQtmK+9dyv17yZLdbOX0Dq8ByHvcZIw/qJ55VbX +fsLzV6vX3vyX7SAMmsvFXQA7YSnoANQ54PJOpAPCElL/53j598AS3DakBaLwa0fi +IT2i4aGRx6yPuK5YZeArV4Drdy1I7+b7ueEg +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem new file mode 100644 index 000000000..393a79825 --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA1kewF2mRTNBmySUuOPKEJ+J8OMwEuQyOPczvS8U1K8GC2EH8 +Jcgk8uAlqvl2POKyUPst7E4W1MHaHuNRn53JcvnLzBScwYLJdh+Y3Q65iiDWq/Km ++S8jgfOv5EcMVZWU3u32pSTuMudreeFCb6QHspUd9amOYEJwQr3iMBhodFIymKmB +2tjGb14dznm28+xP7X0iV9IU0PvyUNSAO4ntd/1FbOZSawpScaxZyNUl9UAD+1Gw +EacAednYTwBDlmhEKUHc0syRyGGVQU0OZlq1FWc+im8p3xyKb+6el5yeaXHTNFJ1 +6ernUXcjmEbKR6LT05cDQUvjMxFyLa+/Kz6zUQIDAQABAoIBAC4sYGuLGf49Ygix +9FXdHFEj4rSyccoWRIhYoq/nHOAC4NkMzvKtQBj95+ABxVK1XstIdMrYwN6zrva8 +8Re9/mzCGwIs5uJj9ll30Y7A34Y+MUP4E7baS4JzKlG8ZZIDm4K2MFHBtXpOl8A5 +pAE+jVIUA9Kt6LohVuNq21SVzdxSfNYC/+SLqSftkWa/ZsqdkiHM5Hl+fVedh516 +IaLNW5hSthGh5n8dHY5h/AKPjfoq77aYp5/CUtJTC9mYdZu1j/W/pBVTRfOnwLQd +SQ1Xmr7f6q9Vmz+HnajIbFg9hQ54blvtUJ7DnugWxfUcoxf7ue79fnYjOIUOkRWw +8Iid/mECgYEA+5s0p0j+gkNZN5QtVNStoT04+1DqA1O381gczeaiz6Njt/MT0y5W +OpCsILQ70CpEjWAV+f6PDJiesDMxdGV+v2TCqK8ml8GahEczBLnHoAkPWCVf2XOX +oNj/CkZ2kmWufHFR+kcQbeDt1vFFcYUa61hKDFyNjinMW79Qy+PQID0CgYEA2gWd +7thE05sqU7/1MntmVRONKoAgnJmHcfSpWwLyh6E4YX3iKDSgI/9RnAMF39KUUY/O +XFWyIwAM9soeXknVsV/SmCaPeaEDiLHqz98aUEfvdLYMnuR883GgoXc4JrLsLw4z +oSi9lbAZFn0ekJ5L+rSFrY4rz9QZgYZxsLjUXKUCgYAbkaUSU2g3w8Np2J2i9u7T +hQ7SUsphdPHqAxSc5xGd6MxLYqIgeKpQHnwN1VHcfFUonIer7d2kxrBUpDdeBqT9 +ub+ulgqHhFo29ko70VNzUKrSwL2g6Q6LPFutt4zUe7nDvvL5loHRWF0XOTafurL5 +aKIsepO0KRZQU0U6IgszDQKBgFs6ZHaP2mTtFY4L0a75Ab3xu20gRgUhHRLq/H6P +wipMpMnuodaPBr9pU53Dig65D8T9Nq1eUnbgy4vs0T5FCPz6iqWN5RVQ8aieQhIP +WfRj1Wfx0WAfXcWEM2G9ACr5TWj3OVVjNclP8X9+hW6gPky+gv03c0+4gZ+4QRRg +ksPdAoGBAKXVv8L5Qt8rmi+QpF/6DVSeMB6lEevf319UEtyZPiYSBn1JJ9H+3Ddc +TMriXgZrw+PoXNJTAguDeGgzmtye1vraP0cjt7aG6sQ4YtMTnnuja880vK8z4i3Q +HJAi5fI8NvlW92dQBAccgrzFIKpRx+6CHwtCkNrxL2vJmGGzp8BR +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/certs/example.crt b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/certs/example.crt new file mode 100644 index 000000000..931874885 --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/certs/example.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEjCCAfqgAwIBAgIJAIAdu56fLE7OMA0GCSqGSIb3DQEBCwUAMBYxFDASBgNV +BAMMC2V4YW1wbGUub3JnMCAXDTIwMDYxMDEwMzM0NFoYDzIxMjAwNTE3MTAzMzQ0 +WjAWMRQwEgYDVQQDDAtleGFtcGxlLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBALv+LV1aWIlcK9rI7IuRS8SCusqBnoyJec/ErKiA2gbfgZ/YS73L +Zud84yp45AIqauzcI5q+hrkmsRZ7CKqDzrG+jHavW7jF+0laetJwRt26AcQcOtQD +2ik2O+Dl1WHAFn4vUAQxb+Xz6WfSaQN0QfM74z06XUDDsr7g7+NYtMzhf98SJSoK +ne/dVKJ3Bc6e6tvhnCRwPtix4ektEodK6WeNHYxwJ6wSZ8cRLzdxgjdD/4OGfFuj +dn8zbOi3SQt5ZqVbcDHUTzp5t0G8EoxnzotHhhzjSAmsypySqZXaxl3oX8aYUkFn +fCdg+WBXo5pOiNfoWh/D5bnIXWGp52yoy+kCAwEAAaNhMF8wHQYDVR0lBBYwFAYI +KwYBBQUHAwIGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFIH+0G3eCswQHbN06kvI80M3 +tNH9MB0GA1UdDgQWBBSxLHQE7gOEyfeSNc5uIO/G/rgjpzANBgkqhkiG9w0BAQsF +AAOCAQEAlGm5RwQ79xmLh3rj+5UViCSgsIuMcuhgIT4zogpo9S4uwXMqinrJhzRk +Oc2tb3y06XTAq1lMH2+58tqndAu8ni/UBz3OSghk2CTnZ1vxxXOd3CtQu4ypMq+k +qW0Umdrkk5TeAODNbrCy4c6vpICkQOljnRFWnDYu3aQ3JvaWZ/nObN7C72Lgpjfb +RfLXGmLsBCEIr028f9hpoeRCXoetUY2CiC2boAHR+cO6Jpvex4Jv5yYDpNKac52n +LLC8Cq5ozhOZNOSV6X9FpEca3rdhVUb0VgoNDCPZDdpO1PDJYCN9fUC8KUs+dsOh +SYGpliBaNsztoiJs1q5SkMQrMwmMmg== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/certs/example.key b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/certs/example.key new file mode 100644 index 000000000..b21c2f08d --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/certs/example.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAu/4tXVpYiVwr2sjsi5FLxIK6yoGejIl5z8SsqIDaBt+Bn9hL +vctm53zjKnjkAipq7Nwjmr6GuSaxFnsIqoPOsb6Mdq9buMX7SVp60nBG3boBxBw6 +1APaKTY74OXVYcAWfi9QBDFv5fPpZ9JpA3RB8zvjPTpdQMOyvuDv41i0zOF/3xIl +Kgqd791UoncFzp7q2+GcJHA+2LHh6S0Sh0rpZ40djHAnrBJnxxEvN3GCN0P/g4Z8 +W6N2fzNs6LdJC3lmpVtwMdRPOnm3QbwSjGfOi0eGHONICazKnJKpldrGXehfxphS +QWd8J2D5YFejmk6I1+haH8PluchdYannbKjL6QIDAQABAoIBAEUBL7WsjAMfihls +1ycD1kPzmIzstz3u2H+jOZ1AbsdHE1WRF3w7RTKDbP8SEN+aolT/GTKb7OfZg/c0 +giHU7/Hed8C47XoNcgei5qKIA/svY6aQlidsoo+uEJykwIZ488itpTlkzCYkOfCa +E2HpMqwNt4OqAMDdFKdr+aIB1Zu+KPBxW23WD9wEWAbe5LA4YnRF9kT4YZ6y9mce +dGaIf39VtBlrGMmvoU0LE9B79nyuebGi0svW6QDarBqaDrnM/N3fXgL1kk/gVfan +/xs6EA4qPxA5G4h+enYrIlZL0CbSj60nYElo+Z5nRdBaRdCF/bpXOLyK/kXWLUM0 +f2HTK+ECgYEA5cVcpJtczxEaxoaEUbppsW1LCrTjJDGTKJ63G7/lwqCxJeCHN185 +nnckHOW2287e19bu9aUmKJgRq5s1rXnT+MnCl/hQnfaMrKOKtzE+t7/zsc9+LuAr +pwJrtZ9Dcnwrk8NOE0fPjW5XpDCSoEOo7JWZmGTVlpabOgNkjocfp+sCgYEA0XPt +ZPt3F0wyzgLYRhgnvp5CV8SzQmulsW+ytnL5eiAcNSXqni3wQHN3PGxLInEyQwBQ +/M8TQpUbqGMmahCK4ZxMAwXMrpF0mVB8jfoYMou1FSYPlUV+CvLjWcTkZB1Nirez +VFXdtfHP0mx4PbYK2qjB03u4pPHAN8kuayIf2nsCgYAbv/FHZAgabfto3Jggcr4P +Ep8MhPolxeL69egxbsSl89hRNcO+2T5ROBxhbRDfjSV2tduYSUDJiEwiCJW8BMmn +814QEopR+ZPVyc6X/1eOw5z/7YpUyPgcrHsrrTdtHTf6GY1VYMfdUeU9zCv5NRKy +uAKb2Bm/nSLUJ9K+L+2PzwKBgQDRQgT3UtTUjehkMitpPFDY/LxDe92simfsMjBW +X+Anx1TnNI6GolbZzYJe98LJElao4fQH38raRqZvQT/rz8MxTDoU+wJXljLryaHn +Jupt9W5hRrli5R7cSXYjBbc43p3N7WJY68CqOoDrNjubS/jkJJ4hcAY1pOHp2jFq +D5nLaQKBgAysU6O5kJ8yKxhbZflb42MqKCFBGrbRnbYx14PAEZOaRhzxehpppQmx +RLbn/z1Uh5Ms28ipxA+vnhyM3FcU5lKboaFyWJeuNslw0FxEcIai6hL6UkDznS4G +aqyzUjpG5Chg0x18xWYCbiGJwjZ9BWhtH+jojm856QHGzeQJWVoo +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/client_ca/example_ca.crt b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/client_ca/example_ca.crt new file mode 100644 index 000000000..b0fa2fa28 --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/client_ca/example_ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDATCCAemgAwIBAgIJAMsPuHg4mqnaMA0GCSqGSIb3DQEBCwUAMBYxFDASBgNV +BAMMC2V4YW1wbGUub3JnMCAXDTIwMDYxMDEwMzMwNloYDzIxMjAwNTE3MTAzMzA2 +WjAWMRQwEgYDVQQDDAtleGFtcGxlLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBAL72D2gOnJN9Zvo9KjruwM1EsFe3xZRJ0NvZ5bHd6+5jhlgCAhQ+ +AGb7ufEiYOi2JWHl2Bkq0iVrp+zv0RLdq0oVjX+OG5H2yWbnC7ifbNjir93LX0un +tIqv5CIbExDSBRkufxfV37yjXdrcMqYSbD2Kw3PfAbWs1Dego8fRz8QAp5+LCvW2 +BZZyYi6JzhCAUW1+8OQPyzOhB50eSJiS5xgVA7wkwmYeVUpHqU8sv4VzjM3bmUc7 +1mPLlnRVIqScrqYgQ9Ou21vZebOJ8+ckVL8O3XHhMZlssBbWiBFZZnaNWbzcEI90 +oiW4YAQ5t7gaXuCVvaiNvq2VZknarR6AxcsCAwEAAaNQME4wHQYDVR0OBBYEFIH+ +0G3eCswQHbN06kvI80M3tNH9MB8GA1UdIwQYMBaAFIH+0G3eCswQHbN06kvI80M3 +tNH9MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJtHx8ibLp+CYDR1 +ZVeQ3qg4OeRL0q21+2EgBJq6zOUt/9SxThA80aXJ8CYH9dnCW+fOnpEk1xFWxtXc +FwSLsnqAdwOJaaQWoMzhyqjZV5x5G9+MW5FzGGOdes2md2Z+tAwMoV9TVtxZkbKy +mC2tDJdvgLgt9/YcbUcZPDbyZojdZ+UbATm+Lro9dhTXt91vsAgz5QA9e08rQVkF +pc9+ZQ5zxBsoblQ+ozPOWOdV4zJVx+wQsAnOG2qU0yVQAscGsTo4wnzFrAU54fO7 +Lh4cOrY0P1/o65yiSzwK7f0jwBeT/jEfMOrJ7pPo7doUov0iVj0SZyTM3HFa2Mzj +2zGTk0o= +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/client_ca/example_ca.key b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/client_ca/example_ca.key new file mode 100644 index 000000000..4f7ad13b1 --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/client_ca/example_ca.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAvvYPaA6ck31m+j0qOu7AzUSwV7fFlEnQ29nlsd3r7mOGWAIC +FD4AZvu58SJg6LYlYeXYGSrSJWun7O/REt2rShWNf44bkfbJZucLuJ9s2OKv3ctf +S6e0iq/kIhsTENIFGS5/F9XfvKNd2twyphJsPYrDc98BtazUN6Cjx9HPxACnn4sK +9bYFlnJiLonOEIBRbX7w5A/LM6EHnR5ImJLnGBUDvCTCZh5VSkepTyy/hXOMzduZ +RzvWY8uWdFUipJyupiBD067bW9l5s4nz5yRUvw7dceExmWywFtaIEVlmdo1ZvNwQ +j3SiJbhgBDm3uBpe4JW9qI2+rZVmSdqtHoDFywIDAQABAoIBAD60bbqtkZycwQPK +seNIIudEduNW5PocgwiuNE6DoMVWyPZ9MlGTSm6GmjgkIc5IgV30K1GYTgkboLic +xvp675QUH7KS51q2vsubcq3dK9DMHxOlhFVDbHVd7HuGiGwtip8KNZGOGTnIKzmC +tN7zjbdnqWaTA+y0I7tgdGdY7fBd9Rzgaq+OlPq2u33HvWHvlG/7PfpZuXB5YLgd +m04l7LJ7ikhIjycg7j27v/4c6xCiH5jMJKsZ+nfsQ0kEEo9DkhcKInK+wHsMzKsH +Cy3AdlE0IRsbxRAoMumVs2g5u90m3zBPkRrNdZ2Ni7BesnhxbkIqvb4SfpxKyuhK +fADfZgECgYEA7SUIS2gII0TGjXh0h16d+eoLVOz0eVpgF3XXxmgtAPu10dqxVEC2 +j5FSBCgZhqZ3axVotP71c2mT+hF+Mqy4TLMfA/B9jKLXjZlPbg4EcAgI7tALskwz +Bk5BkX0k825bU9P0j+AlpLx6/ztHr2N9/cKZfqQVrO9t+FRusvAHkHsCgYEAziT8 +F30Ch2s6IJngCj5jH164iN2CoFXjqPNVgRj45gLE3zrf1R7u2JTeEhjWNLZ6IWZ3 +G/bT7eYm6x8u7LFlORdnWKsHlftGu0igRyvIGcxoHgjXlsLidBaEP+HlOLUtTumu +MfQJUozLcrOBIV6m9VhPnSDTeCg/tOqy68V2pvECgYAUYgd5e8KfTW0Hgd/6Nq67 +aVt5/DfzKkpyGcXnHtMnb3ssQ3DUfg9y/ZmgE9ZF1Y8UHC34yKVOOzfl2ZUQQ/o/ +VXIIA6a27NQ8Ln4+RmQpQPeLl0Q6GgSUuSs3lxsS9VxSMzilGS4DH9QulejOcW3F +3vEUioP2bkn0e0VcifcMewKBgAG1Pr13FLFIiye//qI3GB0nbMH9i9qGO6entHqo +WU+WkEkFNNuQMQxsV1axC/1N0b87GRuLNQBQmtvx2zKs2Zjaf8m1SQ/OECz3EhTk +4PiNwAMXsamXHcc2dIwO9BY/MgvoVcAmNHmRnxHpONWs8hcwTyCPKBFjy/tUwny/ +mxcRAoGAcNRxLZlyRqmQ6zGf41GK4ZIR9gix0L6Km49S1maGFmcbctOR2GcQN8Eo +f38rkrFBfBfuFSGzghJiXDvKORg9r3V/bzSKcXkprJra6hzn5vn+t7wurjzaJUlK +zUW5dl3SU2bC4MK+X7bwqf9jm9b7FXSt4p1xly8Uh/Mufwi7zOM= +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/server_cert_conf.data b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/server_cert_conf.data new file mode 100644 index 000000000..49f228531 --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/server_cert_conf.data @@ -0,0 +1,12 @@ +{ + "Version": "init version", + "Config": { + "Default": "example.org", + "CertConf": { + "example.org": { + "ServerCertFile": "tls_conf/certs/example.crt", + "ServerKeyFile" : "tls_conf/certs/example.key" + } + } + } +} diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/session_ticket_key.data b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/session_ticket_key.data new file mode 100644 index 000000000..b3d2356b0 --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/session_ticket_key.data @@ -0,0 +1,4 @@ +{ + "Version": "init version", + "SessionTicketKey": "08a0d852ef494143af613ef32d3c39314758885f7108e9ab021d55f422a454f7c9cd5a53978f48fa1063eadcdc06878f" +} diff --git a/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/tls_rule_conf.data b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/tls_rule_conf.data new file mode 100644 index 000000000..66e7b5dbb --- /dev/null +++ b/tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/testdata/tls_conf/tls_rule_conf.data @@ -0,0 +1,20 @@ +{ + "Version": "12", + "DefaultNextProtos": ["http/1.1"], + "Config": { + "example_product": { + "VipConf": [ + "10.199.4.14" + ], + "SniConf": ["example.org"], + "CertName": "example.org", + "NextProtos": [ + "h2;rate=100;isw=65535;mcs=200;level=0", + "http/1.1" + ], + "Grade": "C", + "ClientAuth": false, + "ClientCAName": "example_ca" + } + } +} From 30b11b525353f2b57a1ad21ec7280d378fa73df1 Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Mon, 24 Aug 2026 21:07:52 +0800 Subject: [PATCH 08/18] feat(bfe): consume full Redis key from rate-limit export and support key cleanup - Use full Redis key exported by ai-gateway-api for rate-limit counters. - Add DeleteKeys support to Redis client/BNS wrapper for control-plane cleanup. --- bfe_modules/mod_ai_rate_limit/policy_limiter.go | 9 +++++++++ bfe_util/redis_client/client.go | 1 + bfe_util/redis_client/redis_bns.go | 16 ++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/bfe_modules/mod_ai_rate_limit/policy_limiter.go b/bfe_modules/mod_ai_rate_limit/policy_limiter.go index 6309724c3..53215fe84 100644 --- a/bfe_modules/mod_ai_rate_limit/policy_limiter.go +++ b/bfe_modules/mod_ai_rate_limit/policy_limiter.go @@ -16,6 +16,7 @@ package mod_ai_rate_limit import ( "fmt" + "strings" "sync" "sync/atomic" @@ -108,6 +109,11 @@ func buildRpmInstId(rule *RPMRuleConf) string { func buildTpmRedisKey(policyId string, rule *TPMRuleConf) string { if rule.RedisKey != "" { + // 新格式:控制面直接下发完整 Redis Key(以 default_bfe_ 开头)。 + // 旧格式:控制面下发 suffix,需要拼接前缀。 + if strings.HasPrefix(rule.RedisKey, "default_bfe_") { + return rule.RedisKey + } return buildRedisKey(policyId, rule.RedisKey) } return buildRedisKey(policyId, fmt.Sprintf("tpm_%s", buildTpmInstId(rule))) @@ -115,6 +121,9 @@ func buildTpmRedisKey(policyId string, rule *TPMRuleConf) string { func buildRpmRedisKey(policyId string, rule *RPMRuleConf) string { if rule.RedisKey != "" { + if strings.HasPrefix(rule.RedisKey, "default_bfe_") { + return rule.RedisKey + } return buildRedisKey(policyId, rule.RedisKey) } return buildRedisKey(policyId, fmt.Sprintf("rpm_%s", buildRpmInstId(rule))) diff --git a/bfe_util/redis_client/client.go b/bfe_util/redis_client/client.go index 76d317e8c..86b2d22a8 100644 --- a/bfe_util/redis_client/client.go +++ b/bfe_util/redis_client/client.go @@ -36,6 +36,7 @@ type Client interface { PIncr([]string) ([]int64, error) GetInt64(key string) (int64, error) IncrBy(key string, delta int64) (int64, error) + Delete(key string) error NewScript(src string) RedisScript } diff --git a/bfe_util/redis_client/redis_bns.go b/bfe_util/redis_client/redis_bns.go index 719b26a6b..8d0adbdf8 100644 --- a/bfe_util/redis_client/redis_bns.go +++ b/bfe_util/redis_client/redis_bns.go @@ -558,6 +558,22 @@ func (c *RedisClient) IncrBy(key string, delta int64) (int64, error) { return count, nil } +// delete key from redis +func (c *RedisClient) Delete(key string) error { + // get connection from pool + conn := c.getConnByKey(key) + defer conn.Close() + + procStart := time.Now() + _, err := conn.Do("DEL", key) + if err != nil { + return err + } + + c.setDelayState(c.delay, procStart) + return nil +} + // incr and expire key to redis func (c *RedisClient) IncrAndExpire(key string, expire int) (int64, error) { c.incrModuleState2(RedisIncr) From f129382758f701ece88b5c2b62a2e52fd9e1fa73 Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Tue, 25 Aug 2026 09:51:55 +0800 Subject: [PATCH 09/18] fix: model modification when retry between clusters in ServeHTTPForAI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache bodyModel across fallback/retry attempts to avoid repeated JSON parsing. - Recompute targetModel from ClientModel for every cluster attempt. - Unconditionally update aiMeta.TargetModel. - Add computeTargetModel helper and unit tests. - Fix mockRedisClient to implement redis_client.Client.Delete. - Add integration tests and design docs for SC06/SC07/SC08. 本次 commit 合并了 https://github.com/bfenetworks/bfe/commit/5cc333502b7c909879c988eb5c9b176dad228733 中提交的修改。 Fixes https://github.com/bfenetworks/bfe/issues/1323 Signed-off-by: zhangmiao --- .../mod_ai_token_auth_test.go | 5 + bfe_server/reverseproxy.go | 119 +++++---- bfe_server/reverseproxy_ai_test.go | 102 ++++++++ .../design-changes.md | 157 ++++++++++++ .../sys_design/mod_ai_route_bfe_changes.md | 104 ++++++-- .../provider_model_prefix_routing.md | 81 +++++-- .../sc08_ai_model_fallback_recompute_test.go | 228 ++++++++++++++++++ .../testdata/bfe.conf | 36 +++ .../testdata/cluster_conf/gslb.data | 18 ++ .../testdata/mod_ai_route/ai_route.data | 44 ++++ .../testdata/mod_ai_route/mod_ai_route.conf | 5 + .../testdata/server_data_conf/host_rule.data | 13 + .../testdata/server_data_conf/route_rule.data | 12 + .../testdata/server_data_conf/vip_rule.data | 8 + .../testdata/tls_conf/backend_rs/bfe_i_ca.crt | 23 ++ .../testdata/tls_conf/backend_rs/bfe_r_ca.crt | 22 ++ .../tls_conf/backend_rs/r_bfe_dev.crt | 85 +++++++ .../tls_conf/backend_rs/r_bfe_dev_prv.pem | 27 +++ .../tls_conf/backend_rs/r_san_example.org.crt | 85 +++++++ .../backend_rs/r_san_example.org_prv.pem | 27 +++ .../testdata/tls_conf/certs/example.crt | 19 ++ .../testdata/tls_conf/certs/example.key | 27 +++ .../tls_conf/client_ca/example_ca.crt | 19 ++ .../tls_conf/client_ca/example_ca.key | 27 +++ .../testdata/tls_conf/server_cert_conf.data | 12 + .../testdata/tls_conf/session_ticket_key.data | 4 + .../testdata/tls_conf/tls_rule_conf.data | 20 ++ ...16\346\240\274\350\275\254\345\217\221.md" | 56 +++++ ...16\346\240\274\350\275\254\345\217\221.md" | 57 +++++ ...255OpenAI-only\351\233\206\347\276\244.md" | 55 +++++ ...Anthropic-only\351\233\206\347\276\244.md" | 55 +++++ ...44\347\247\215\350\257\267\346\261\202.md" | 63 +++++ ...6Authorization\344\274\230\345\205\210.md" | 54 +++++ ...on\350\242\253\344\277\235\347\225\231.md" | 55 +++++ ...27\346\256\265\350\247\243\346\236\220.md" | 65 +++++ ...35\346\214\201\350\277\236\347\273\255.md" | 67 +++++ ...35\346\214\201\350\277\236\347\273\255.md" | 63 +++++ ...31\345\220\215\345\233\236\351\200\200.md" | 64 +++++ ...72\346\231\257\350\257\264\346\230\216.md" | 183 ++++++++++++++ ...15\346\226\260\350\256\241\347\256\227.md" | 94 ++++++++ ...0\272\253strip\351\205\215\347\275\256.md" | 96 ++++++++ ...72\346\231\257\350\257\264\346\230\216.md" | 176 ++++++++++++++ ...73\344\275\223\350\257\264\346\230\216.md" | 31 +++ 43 files changed, 2466 insertions(+), 97 deletions(-) create mode 100644 docs/zh_cn/modifications/2026-08-24-ai-model-fallback-body-cache/design-changes.md create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/sc08_ai_model_fallback_recompute_test.go create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/bfe.conf create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/cluster_conf/gslb.data create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/mod_ai_route/ai_route.data create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/mod_ai_route/mod_ai_route.conf create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/server_data_conf/host_rule.data create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/server_data_conf/route_rule.data create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/server_data_conf/vip_rule.data create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/bfe_i_ca.crt create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/bfe_r_ca.crt create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_bfe_dev.crt create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_san_example.org.crt create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/certs/example.crt create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/certs/example.key create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/client_ca/example_ca.crt create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/client_ca/example_ca.key create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/server_cert_conf.data create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/session_ticket_key.data create mode 100644 tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/tls_rule_conf.data create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-01-OpenAI\351\243\216\346\240\274\350\275\254\345\217\221.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-02-Anthropic\351\243\216\346\240\274\350\275\254\345\217\221.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-03-Anthropic\350\257\267\346\261\202\345\221\275\344\270\255OpenAI-only\351\233\206\347\276\244.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-04-OpenAI\350\257\267\346\261\202\345\221\275\344\270\255Anthropic-only\351\233\206\347\276\244.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-05-\345\244\232\345\215\217\350\256\256\351\233\206\347\276\244\345\220\214\346\227\266\346\224\257\346\214\201\344\270\244\347\247\215\350\257\267\346\261\202.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-06-\345\217\214\345\244\264\345\255\230\345\234\250\346\227\266Authorization\344\274\230\345\205\210.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-07-\346\230\276\345\274\217anthropic-version\350\242\253\344\277\235\347\225\231.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-08-Claude usage\345\255\227\346\256\265\350\247\243\346\236\220.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/TC-01-\350\247\204\345\210\231\351\207\215\345\221\275\345\220\215\345\220\216RPM\350\256\241\346\225\260\345\231\250\344\277\235\346\214\201\350\277\236\347\273\255.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/TC-02-\346\250\241\345\236\213\345\217\230\346\233\264\345\220\216RPM\350\256\241\346\225\260\345\231\250\344\277\235\346\214\201\350\277\236\347\273\255.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/TC-03-\346\234\252\351\205\215\347\275\256redis_key\346\227\266\346\214\211\350\247\204\345\210\231\345\220\215\345\233\236\351\200\200.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/\345\234\272\346\231\257\350\257\264\346\230\216.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC08-AI\346\250\241\345\236\213Fallback\351\207\215\347\256\227/TC-01-Fallback\344\273\216ClientModel\351\207\215\346\226\260\350\256\241\347\256\227.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC08-AI\346\250\241\345\236\213Fallback\351\207\215\347\256\227/TC-02-Fallback\345\272\224\347\224\250\350\207\252\350\272\253strip\351\205\215\347\275\256.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC08-AI\346\250\241\345\236\213Fallback\351\207\215\347\256\227/\345\234\272\346\231\257\350\257\264\346\230\216.md" diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go index 6cfe3ee60..ba4ec163e 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go @@ -1433,6 +1433,11 @@ func (m *mockRedisClient) IncrBy(key string, delta int64) (int64, error) { return m.data[key], nil } +func (m *mockRedisClient) Delete(key string) error { + delete(m.data, key) + return nil +} + func (m *mockRedisClient) NewScript(src string) redis_client.RedisScript { return &mockRedisScript{client: m, src: src} } diff --git a/bfe_server/reverseproxy.go b/bfe_server/reverseproxy.go index f0f488557..c80360303 100644 --- a/bfe_server/reverseproxy.go +++ b/bfe_server/reverseproxy.go @@ -1122,6 +1122,7 @@ func (p *ReverseProxy) ServeHTTPForAI(rw bfe_http.ResponseWriter, basicReq *bfe_ var attempts []aiForwardAttempt var lastCluster *bfe_cluster.BfeCluster var invokeErr error + var bodyModel string isRedirect := false resFlushInterval := time.Duration(0) @@ -1277,6 +1278,13 @@ func (p *ReverseProxy) ServeHTTPForAI(rw bfe_http.ResponseWriter, basicReq *bfe_ } } + // bodyModel tracks the model currently in the request body. + // aiMeta.ClientModel was already extracted from the request body before + // entering the reverse proxy, so we can use it as the initial value without + // parsing the body again. Only this loop modifies the body model, so we + // keep the value in a variable instead of parsing the body on every attempt. + bodyModel = aiMeta.ClientModel + for i, attempt := range attempts { if i > 0 { // fallback attempt: reset request state @@ -1286,7 +1294,7 @@ func (p *ReverseProxy) ServeHTTPForAI(rw bfe_http.ResponseWriter, basicReq *bfe_ } } - res, action, lastCluster, invokeErr = p.aiClusterInvoke(srv, serverConf, basicReq, rw, attempt, aiMeta) + res, action, lastCluster, invokeErr, bodyModel = p.aiClusterInvoke(srv, serverConf, basicReq, rw, attempt, aiMeta, bodyModel) if invokeErr == nil && res != nil && res.StatusCode < 400 { // success: 2xx/3xx, stop fallback loop break @@ -1443,12 +1451,44 @@ func stripProviderPrefix(model string, matchPrefix string) (string, bool) { return stripped, true } +// computeTargetModel calculates the final model name for a single AI forward +// attempt. It always starts from clientModel so that fallback clusters recompute +// from the original client value instead of inheriting the previous cluster's +// target model. The calculation order is: +// 1. attempt.Model override (if non-empty) +// 2. strip provider/model prefix according to aiConf +// 3. apply cluster model mapping according to aiConf +func computeTargetModel(clientModel string, attemptModel string, aiConf *cluster_conf.AIConf) string { + targetModel := clientModel + + // apply model override from ai route target/fallback + if attemptModel != "" { + targetModel = attemptModel + } + + // strip provider/model prefix according to cluster AIConf + if aiConf != nil && aiConf.StripPrefix && aiConf.MatchPrefix != "" { + if stripped, ok := stripProviderPrefix(targetModel, aiConf.MatchPrefix); ok { + targetModel = stripped + } + } + + // apply cluster model mapping + if aiConf != nil && aiConf.ModelMapping != nil && targetModel != "" { + if mapped, ok := (*aiConf.ModelMapping)[targetModel]; ok { + targetModel = mapped + } + } + + return targetModel +} + // doSingleAIForward performs a single AI forward attempt with the given key. func (p *ReverseProxy) doSingleAIForward(srv *BfeServer, cluster *bfe_cluster.BfeCluster, basicReq *bfe_basic.Request, rw bfe_http.ResponseWriter, attempt aiForwardAttempt, aiMeta *bfe_basic.AiBasicInfo, - selectedKey cluster_conf.AIKey) ( - res *bfe_http.Response, action int, err error) { + selectedKey cluster_conf.AIKey, bodyModel string) ( + res *bfe_http.Response, action int, err error, newBodyModel string) { req := basicReq.HttpRequest @@ -1465,7 +1505,7 @@ func (p *ReverseProxy) doSingleAIForward(srv *BfeServer, cluster *bfe_cluster.Bf fmt.Sprintf("request protocol %s not supported by cluster provider (model_protocols=%v)", aiMeta.AuthStyle, cluster.AIConf.ModelProtocols), ) - return err.CreateErrorResponse(basicReq), closeAfterReply, nil + return err.CreateErrorResponse(basicReq), closeAfterReply, nil, bodyModel } // prepare out request to downstream RS backend @@ -1485,33 +1525,19 @@ func (p *ReverseProxy) doSingleAIForward(srv *BfeServer, cluster *bfe_cluster.Bf // Calculate the final model in order: route target/fallback override -> // provider/model prefix stripping -> cluster model mapping. Then write it - // to the request body at most once to avoid repeated JSON parsing/serialization. - model := aiMeta.ClientModel - if aiMeta.TargetModel != "" { - model = aiMeta.TargetModel - } - - // apply model override from ai route target/fallback - if attempt.Model != "" { - model = attempt.Model - } - - // strip provider/model prefix according to cluster AIConf - if cluster.AIConf != nil && cluster.AIConf.StripPrefix && cluster.AIConf.MatchPrefix != "" { - if stripped, ok := stripProviderPrefix(model, cluster.AIConf.MatchPrefix); ok { - model = stripped - } - } - - // apply cluster model mapping - if cluster.AIConf != nil && cluster.AIConf.ModelMapping != nil && model != "" { - if newModel, ok := (*cluster.AIConf.ModelMapping)[model]; ok { - model = newModel - } - } - - if model != aiMeta.ClientModel { - if err := condition.ReqBodyJsonSet(basicReq, "model", model); err != nil { + // to the request body only when it differs from the current body value. + // Always start from ClientModel for every cluster attempt, so fallbacks + // recompute the target model from the original client value. + targetModel := computeTargetModel(aiMeta.ClientModel, attempt.Model, cluster.AIConf) + + // record the final target model for this cluster attempt + aiMeta.TargetModel = targetModel + + // bodyModel is cached by the caller and updated here only when we actually + // change the body, so we avoid parsing the request body on every attempt. + newBodyModel = bodyModel + if targetModel != bodyModel { + if err := condition.ReqBodyJsonSet(basicReq, "model", targetModel); err != nil { log.Logger.Warn("Failed to set model in request body: %s", err) } else { // outreq body already changed, need reset Content-Length @@ -1525,7 +1551,7 @@ func (p *ReverseProxy) doSingleAIForward(srv *BfeServer, cluster *bfe_cluster.Bf basicReq.HttpRequest.ContentLength = -1 basicReq.HttpRequest.Header.Del("Content-Length") } - aiMeta.TargetModel = model + newBodyModel = targetModel } } @@ -1551,15 +1577,17 @@ func (p *ReverseProxy) doSingleAIForward(srv *BfeServer, cluster *bfe_cluster.Bf } // invoke cluster to get response - return p.clusterInvoke(srv, cluster, basicReq, rw) + res, action, err = p.clusterInvoke(srv, cluster, basicReq, rw) + return res, action, err, newBodyModel } func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.ServerDataConf, basicReq *bfe_basic.Request, rw bfe_http.ResponseWriter, - attempt aiForwardAttempt, aiMeta *bfe_basic.AiBasicInfo) ( - res *bfe_http.Response, action int, cluster *bfe_cluster.BfeCluster, err error) { + attempt aiForwardAttempt, aiMeta *bfe_basic.AiBasicInfo, bodyModel string) ( + res *bfe_http.Response, action int, cluster *bfe_cluster.BfeCluster, err error, newBodyModel string) { req := basicReq.HttpRequest + newBodyModel = bodyModel // update route info basicReq.Route.ClusterName = attempt.ClusterName @@ -1573,7 +1601,7 @@ func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.Ser basicReq.ErrCode = bfe_basic.ErrBkNoCluster basicReq.ErrMsg = err.Error() p.proxyState.ErrBkNoCluster.Inc(1) - return nil, closeAfterReply, nil, err + return nil, closeAfterReply, nil, err, newBodyModel } // set deadline to finish read client request body @@ -1587,8 +1615,8 @@ func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.Ser // no api keys configured, skip key injection if cluster.AIConf == nil || len(cluster.AIConf.Keys) == 0 { - res, action, err = p.doSingleAIForward(srv, cluster, basicReq, rw, attempt, aiMeta, cluster_conf.AIKey{}) - return res, action, cluster, err + res, action, err, newBodyModel = p.doSingleAIForward(srv, cluster, basicReq, rw, attempt, aiMeta, cluster_conf.AIKey{}, newBodyModel) + return res, action, cluster, err, newBodyModel } policy := defaultAIKeyPolicy() @@ -1643,7 +1671,7 @@ func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.Ser } keepKey = false - res, action, err = p.doSingleAIForward(srv, cluster, basicReq, rw, attempt, aiMeta, key) + res, action, err, newBodyModel = p.doSingleAIForward(srv, cluster, basicReq, rw, attempt, aiMeta, key, newBodyModel) lastErr = err statusCode := 0 @@ -1653,7 +1681,7 @@ func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.Ser // success: stop key-level retry if err == nil && statusCode < 400 { - return res, action, cluster, nil + return res, action, cluster, nil, newBodyModel } // classify failure @@ -1674,11 +1702,11 @@ func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.Ser key.Name, statusCode, err) default: // other 4xx client errors (e.g. 400, 404): stop key-level retry - return res, action, cluster, nil + return res, action, cluster, nil, newBodyModel } } - return res, action, cluster, lastErr + return res, action, cluster, lastErr, newBodyModel } // aiFallbackStatusCodes defines the 4xx status codes that should trigger @@ -1732,13 +1760,6 @@ func (p *ReverseProxy) resetRequestForRetry(basicReq *bfe_basic.Request) bool { return false } - // reset Content-Length so that the next outreq is created with a length - // consistent with the current (possibly modified) body. - if basicReq.HttpRequest.ContentLength >= 0 { - basicReq.HttpRequest.ContentLength = -1 - basicReq.HttpRequest.Header.Del("Content-Length") - } - // clear error info from previous attempt basicReq.ErrCode = nil basicReq.ErrMsg = "" diff --git a/bfe_server/reverseproxy_ai_test.go b/bfe_server/reverseproxy_ai_test.go index 12ce066d8..e79274bc1 100644 --- a/bfe_server/reverseproxy_ai_test.go +++ b/bfe_server/reverseproxy_ai_test.go @@ -279,3 +279,105 @@ func TestStripProviderPrefixEmptyResult(t *testing.T) { t.Errorf("expected model unchanged, got %s", stripped) } } + +func TestComputeTargetModelNoOverride(t *testing.T) { + model := computeTargetModel("gpt-4", "", nil) + if model != "gpt-4" { + t.Errorf("expected gpt-4, got %s", model) + } +} + +func TestComputeTargetModelAttemptOverride(t *testing.T) { + model := computeTargetModel("gpt-4", "gpt-3.5", nil) + if model != "gpt-3.5" { + t.Errorf("expected gpt-3.5, got %s", model) + } +} + +func TestComputeTargetModelStripPrefix(t *testing.T) { + aiConf := &cluster_conf.AIConf{ + MatchPrefix: "openrouter/", + StripPrefix: true, + } + model := computeTargetModel("openrouter/modelA", "", aiConf) + if model != "modelA" { + t.Errorf("expected modelA, got %s", model) + } +} + +func TestComputeTargetModelStripPrefixAfterOverride(t *testing.T) { + aiConf := &cluster_conf.AIConf{ + MatchPrefix: "openrouter/", + StripPrefix: true, + } + model := computeTargetModel("clientprefix/mymodel", "openrouter/modelA", aiConf) + if model != "modelA" { + t.Errorf("expected modelA, got %s", model) + } +} + +func TestComputeTargetModelModelMapping(t *testing.T) { + mappings := map[string]string{"modelA": "mapped-model"} + aiConf := &cluster_conf.AIConf{ + ModelMapping: &mappings, + } + model := computeTargetModel("modelA", "", aiConf) + if model != "mapped-model" { + t.Errorf("expected mapped-model, got %s", model) + } +} + +func TestComputeTargetModelFullChain(t *testing.T) { + mappings := map[string]string{"modelA": "mapped-model"} + aiConf := &cluster_conf.AIConf{ + MatchPrefix: "openrouter/", + StripPrefix: true, + ModelMapping: &mappings, + } + model := computeTargetModel("clientprefix/original", "openrouter/modelA", aiConf) + if model != "mapped-model" { + t.Errorf("expected mapped-model, got %s", model) + } +} + +func TestComputeTargetModelFallbackRecompute(t *testing.T) { + // Simulates fallback scenario: primary cluster mapped clientprefix/mymodel + // to mapped-primary-model; fallback cluster should recompute from ClientModel. + primaryMappings := map[string]string{"modelA": "mapped-primary-model"} + primaryConf := &cluster_conf.AIConf{ + MatchPrefix: "openrouter/", + StripPrefix: true, + ModelMapping: &primaryMappings, + } + primaryModel := computeTargetModel("clientprefix/mymodel", "openrouter/modelA", primaryConf) + if primaryModel != "mapped-primary-model" { + t.Errorf("expected mapped-primary-model, got %s", primaryModel) + } + + fallbackModel := computeTargetModel("clientprefix/mymodel", "", nil) + if fallbackModel != "clientprefix/mymodel" { + t.Errorf("expected fallback to recompute from ClientModel, got %s", fallbackModel) + } +} + +func TestComputeTargetModelStripPrefixNoMatch(t *testing.T) { + aiConf := &cluster_conf.AIConf{ + MatchPrefix: "openrouter/", + StripPrefix: true, + } + model := computeTargetModel("anthropic/claude", "", aiConf) + if model != "anthropic/claude" { + t.Errorf("expected anthropic/claude unchanged, got %s", model) + } +} + +func TestComputeTargetModelMappingNoMatch(t *testing.T) { + mappings := map[string]string{"modelA": "mapped-model"} + aiConf := &cluster_conf.AIConf{ + ModelMapping: &mappings, + } + model := computeTargetModel("modelB", "", aiConf) + if model != "modelB" { + t.Errorf("expected modelB unchanged, got %s", model) + } +} diff --git a/docs/zh_cn/modifications/2026-08-24-ai-model-fallback-body-cache/design-changes.md b/docs/zh_cn/modifications/2026-08-24-ai-model-fallback-body-cache/design-changes.md new file mode 100644 index 000000000..cce185f85 --- /dev/null +++ b/docs/zh_cn/modifications/2026-08-24-ai-model-fallback-body-cache/design-changes.md @@ -0,0 +1,157 @@ +# 修正 AI 网关 model 字段在 fallback 与重试时的计算逻辑 + +## 1. 背景 + +`bfe/bfe_server/reverseproxy.go` 中的 `ServeHTTPForAI()` 负责 AI 网关请求的转发,并在 `doSingleAIForward()` 中根据路由目标、cluster 配置改写请求体里的 `model` 字段。此前实现存在以下问题: + +1. **fallback 时未从 `ClientModel` 重新计算**。`doSingleAIForward()` 先从 `aiMeta.TargetModel` 读取当前 model,导致 fallback 到新 cluster 时继承了上一次改写后的 model。 +2. **body 改写判断基准错误**。用 `model != aiMeta.ClientModel` 判断是否写 body,但 fallback 后 body 里可能已经是上一个 cluster 的 target model,此时若新 cluster 算出的目标恰好等于 `ClientModel`,会错误地跳过改写。 +3. **`aiMeta.TargetModel` 更新不完整**。只在 body 被改写且成功时才更新 `TargetModel`,当目标 model 与 `ClientModel` 相同时会保留旧值。 +4. **`HttpReqBodyJsonGet` 调用次数偏多**。每次 `doSingleAIForward()` 都会读取 body 中的 model,同 cluster 的 key 级重试也会重复读取。 + +## 2. 需求澄清 + +1. 客户端请求中的 model 字段称为 `ClientModel`,缓存在 `aiMeta.ClientModel`。 +2. 最终转发给后端服务的 model 称为 `TargetModel`,缓存在 `aiMeta.TargetModel`。 +3. `TargetModel` 的修改步骤: + - 初始值 = `ClientModel`; + - 若匹配的 `AiRouteTarget` 或 `AiRouteFallback` 存在 `Model`,则 `TargetModel = attempt.Model`; + - 若 cluster 配置要求 `StripPrefix`,则从 `TargetModel` 去掉 prefix; + - 若 cluster 配置了 `ModelMapping`,则 `TargetModel` 按映射表转成最终值; + - 根据最终 `TargetModel` 修改 body 中的 `model` 字段。 +4. 需要重试时,对新的 cluster 要重复上述步骤。注意 body 中可能已经是上一个路由目标或 cluster 修改后的 model,不要误传。 +5. 尽量减少 `ReqBodyJsonSet` 调用:用变量缓存 body 中的实际 model,和 `TargetModel` 不同才修改。 + +## 3. 变更目标 + +1. 每个 cluster 尝试都从 `ClientModel` 开始计算 `TargetModel`。 +2. 每次 `doSingleAIForward()` 都无条件更新 `aiMeta.TargetModel`。 +3. 用变量缓存 body 中的实际 model,仅在真正变化时调用 `ReqBodyJsonSet`。 +4. 保持 model 覆盖 → prefix strip → model mapping 的原有执行顺序。 +5. 同步更新相关设计文档,确保伪代码与实际代码一致。 + +## 4. 变更总览 + +| 层级 | 变更点 | 影响文件 | +|---|---|---| +| 转发层 | `ServeHTTPForAI()` 增加 `bodyModel` 缓存并透传;`aiClusterInvoke()` / `doSingleAIForward()` 增加 `bodyModel` 入参与返回值 | `bfe/bfe_server/reverseproxy.go` | +| 文档 | 更新 `provider_model_prefix_routing.md`、`mod_ai_route_bfe_changes.md` 中的 model 计算伪代码与重试分析 | `docs/zh_cn/sys_design/...` | + +## 5. 详细设计 + +### 5.1 `bodyModel` 缓存与透传 + +`ServeHTTPForAI()` 在 cluster 级 fallback 循环开始前从 `aiMeta.ClientModel` 初始化 `bodyModel`。`aiMeta.ClientModel` 在进入反向代理前已从请求体中解析出来,因此这里不需要再解析 body。后续每次 cluster/key 级尝试都复用 `bodyModel` 变量,并随 `aiClusterInvoke()` → `doSingleAIForward()` 调用链透传: + +```go +// bodyModel tracks the model currently in the request body. +// aiMeta.ClientModel was already extracted from the request body before +// entering the reverse proxy, so we can use it as the initial value without +// parsing the body again. Only this loop modifies the body model, so we +// keep the value in a variable instead of parsing the body on every attempt. +bodyModel = aiMeta.ClientModel + +for i, attempt := range attempts { + ... + res, action, lastCluster, invokeErr, bodyModel = + p.aiClusterInvoke(srv, serverConf, basicReq, rw, attempt, aiMeta, bodyModel) + ... +} +``` + +`aiClusterInvoke()` 与 `doSingleAIForward()` 的签名同步扩展: + +```go +func (p *ReverseProxy) aiClusterInvoke(..., bodyModel string) ( + res *bfe_http.Response, action int, cluster *bfe_cluster.BfeCluster, err error, newBodyModel string) + +func (p *ReverseProxy) doSingleAIForward(..., bodyModel string) ( + res *bfe_http.Response, action int, err error, newBodyModel string) +``` + +### 5.2 `TargetModel` 计算与 body 改写 + +`doSingleAIForward()` 中: + +1. 每个 cluster 尝试都从 `aiMeta.ClientModel` 开始计算,不再读取 `aiMeta.TargetModel`。 +2. 按 `attempt.Model` → `StripPrefix` → `ModelMapping` 顺序得到 `targetModel`。 +3. 无条件更新 `aiMeta.TargetModel = targetModel`。 +4. 比较 `targetModel` 与缓存的 `bodyModel`,只有不一致时才调用 `ReqBodyJsonSet`;改写成功后更新 `newBodyModel = targetModel`。 + +```go +// Calculate the final model in order: route target/fallback override -> +// provider/model prefix stripping -> cluster model mapping. Then write it +// to the request body only when it differs from the current body value. +// Always start from ClientModel for every cluster attempt, so fallbacks +// recompute the target model from the original client value. +targetModel := aiMeta.ClientModel + +// apply model override from ai route target/fallback +if attempt.Model != "" { + targetModel = attempt.Model +} + +// strip provider/model prefix according to cluster AIConf +if cluster.AIConf != nil && cluster.AIConf.StripPrefix && cluster.AIConf.MatchPrefix != "" { + if stripped, ok := stripProviderPrefix(targetModel, cluster.AIConf.MatchPrefix); ok { + targetModel = stripped + } +} + +// apply cluster model mapping +if cluster.AIConf != nil && cluster.AIConf.ModelMapping != nil && targetModel != "" { + if mapped, ok := (*cluster.AIConf.ModelMapping)[targetModel]; ok { + targetModel = mapped + } +} + +// record the final target model for this cluster attempt +aiMeta.TargetModel = targetModel + +// bodyModel is cached by the caller and updated here only when we actually +// change the body, so we avoid parsing the request body on every attempt. +newBodyModel = bodyModel +if targetModel != bodyModel { + if err := condition.ReqBodyJsonSet(basicReq, "model", targetModel); err != nil { + log.Logger.Warn("Failed to set model in request body: %s", err) + } else { + // outreq body already changed, need reset Content-Length + if outreq.ContentLength >= 0 { + outreq.ContentLength = -1 + outreq.Header.Del("Content-Length") + } + // Also reset the original request's Content-Length so that fallback/retry + // creates a new outreq with consistent body length. + if basicReq.HttpRequest != nil && basicReq.HttpRequest.ContentLength >= 0 { + basicReq.HttpRequest.ContentLength = -1 + basicReq.HttpRequest.Header.Del("Content-Length") + } + newBodyModel = targetModel + } +} +``` + +### 5.3 fallback 与重试正确性 + +- **cluster 级 fallback**:新 cluster 从 `ClientModel` 重新计算,配合 `bodyModel` 缓存,能正确识别 body 中是否仍是上一个 cluster 的 target model,并在需要时改回或改成新的 target model。 +- **key 级重试**:同一个 cluster 配置不变,`targetModel` 不变;第一次重试改写 body 后 `newBodyModel = targetModel`,后续重试传入的 `bodyModel` 已等于 targetModel,不会重复调用 `ReqBodyJsonSet`。 + +## 6. 关键代码索引 + +| 文件 | 行号范围 | 说明 | +|---|---|---| +| `bfe/bfe_server/reverseproxy.go` | 1280-1297 | `ServeHTTPForAI()` 中 `bodyModel` 缓存与透传 | +| `bfe/bfe_server/reverseproxy.go` | 1454-1546 | `doSingleAIForward()` 中 `TargetModel` 计算与 body 改写 | +| `bfe/bfe_server/reverseproxy.go` | 1548-1670 | `aiClusterInvoke()` 透传 `bodyModel` | + +## 7. 测试验证 + +- `go test ./bfe_server/...` 通过。 +- `make test` 全量通过,包括: + - `scenario-SC04-provider-model-prefix-strip` + - `scenario-SC05-access-log-ai-fields` + +## 8. 文档更新 + +- 更新 `docs/zh_cn/sys_design/provider_model_prefix_routing.md` 第 4、5 节,同步 model 计算伪代码与重试分析。 +- 更新 `docs/zh_cn/sys_design/mod_ai_route_bfe_changes.md` 第 4.2、4.3.2、7 节,同步 `bodyModel` 透传与模型覆盖逻辑。 diff --git a/docs/zh_cn/sys_design/mod_ai_route_bfe_changes.md b/docs/zh_cn/sys_design/mod_ai_route_bfe_changes.md index 5b95c33a2..cf9691566 100644 --- a/docs/zh_cn/sys_design/mod_ai_route_bfe_changes.md +++ b/docs/zh_cn/sys_design/mod_ai_route_bfe_changes.md @@ -312,6 +312,13 @@ func (p *ReverseProxy) ServeHTTPForAI(rw bfe_http.ResponseWriter, basicReq *bfe_ } } + // bodyModel tracks the model currently in the request body. + // aiMeta.ClientModel was already extracted from the request body before + // entering the reverse proxy, so we can use it as the initial value without + // parsing the body again. Only this loop modifies the body model, so we + // keep the value in a variable instead of parsing the body on every attempt. + bodyModel := aiMeta.ClientModel + for i, attempt := range attempts { if i > 0 { // fallback attempt: reset request state @@ -321,9 +328,10 @@ func (p *ReverseProxy) ServeHTTPForAI(rw bfe_http.ResponseWriter, basicReq *bfe_ } } - res, action, lastCluster, invokeErr = p.aiClusterInvoke(srv, serverConf, basicReq, rw, attempt, aiMeta) - if invokeErr == nil && res != nil && res.StatusCode < 500 { - // success or 4xx (client error, do not fallback) + res, action, lastCluster, invokeErr, bodyModel = + p.aiClusterInvoke(srv, serverConf, basicReq, rw, attempt, aiMeta, bodyModel) + if invokeErr == nil && res != nil && res.StatusCode < 400 { + // success: 2xx/3xx, stop fallback loop break } @@ -397,10 +405,11 @@ type aiForwardAttempt struct { ```go func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.ServerDataConf, basicReq *bfe_basic.Request, rw bfe_http.ResponseWriter, - attempt aiForwardAttempt, aiMeta *bfe_basic.AiBasicInfo) ( - res *bfe_http.Response, action int, cluster *bfe_cluster.BfeCluster, err error) { + attempt aiForwardAttempt, aiMeta *bfe_basic.AiBasicInfo, bodyModel string) ( + res *bfe_http.Response, action int, cluster *bfe_cluster.BfeCluster, err error, newBodyModel string) { req := basicReq.HttpRequest + newBodyModel = bodyModel // update route info basicReq.Route.ClusterName = attempt.ClusterName @@ -414,7 +423,7 @@ func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.Ser basicReq.ErrCode = bfe_basic.ErrBkNoCluster basicReq.ErrMsg = err.Error() p.proxyState.ErrBkNoCluster.Inc(1) - return nil, closeAfterReply, nil, err + return nil, closeAfterReply, nil, err, newBodyModel } // set deadline to finish read client request body @@ -426,8 +435,8 @@ func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.Ser // no API-Key configured: single forward if cluster.AIConf == nil || len(cluster.AIConf.Keys) == 0 { - res, action, err = p.doSingleAIForward(srv, cluster, basicReq, rw, attempt, aiMeta, cluster_conf.AIKey{}) - return res, action, cluster, err + res, action, err, newBodyModel = p.doSingleAIForward(srv, cluster, basicReq, rw, attempt, aiMeta, cluster_conf.AIKey{}, newBodyModel) + return res, action, cluster, err, newBodyModel } // multi API-Key selection and retry loop @@ -451,7 +460,7 @@ func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.Ser break } - res, action, err = p.doSingleAIForward(srv, cluster, basicReq, rw, attempt, aiMeta, key) + res, action, err, newBodyModel = p.doSingleAIForward(srv, cluster, basicReq, rw, attempt, aiMeta, key, newBodyModel) lastErr = err statusCode := 0 @@ -459,17 +468,17 @@ func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.Ser statusCode = res.StatusCode } - // success or 4xx client error: stop key-level retry - if err == nil && statusCode < 500 { - return res, action, cluster, nil + // success: stop key-level retry + if err == nil && statusCode < 400 { + return res, action, cluster, nil, newBodyModel } // classify failure and decide next key/retry - // 429 -> rotate key; 401/403 -> dead key; 5xx/err -> same key retry with backoff + // 429 -> rotate key; 401/402/403 -> dead key; 5xx/err -> same key retry with backoff classifyAIKeyFailure(idx, statusCode, err, state) } - return res, action, cluster, lastErr + return res, action, cluster, lastErr, newBodyModel } ``` @@ -616,23 +625,60 @@ func SelectTarget(targets []bfe_basic.AiRouteTarget) bfe_basic.AiRouteTarget { ### 7.1 覆盖优先级 -1. **target/fallback.Model 非空**:覆盖请求体中的 `model` 字段; -2. **cluster.AIConf.ModelMapping**:将当前 `model`(可能是原始 model 或 target 覆盖后的 model)映射为后端模型; -3. **均空**:透传原始 `model`。 +每个 cluster 尝试都**从 `aiMeta.ClientModel` 开始**计算,按以下顺序得到最终目标模型名: + +1. **target/fallback.Model 非空**:覆盖为路由目标/降级指定的模型; +2. **cluster.AIConf.MatchPrefix** 前缀裁剪(若 `StripPrefix=true`); +3. **cluster.AIConf.ModelMapping** 模型映射(若命中)。 ### 7.2 请求体处理 -`doSingleAIForward()` 中按以下顺序计算最终模型名: +`ServeHTTPForAI()` 在 cluster 级 fallback 循环开始前从 `aiMeta.ClientModel` 初始化 `bodyModel`。`aiMeta.ClientModel` 在进入反向代理前已从请求体中解析出来,因此这里不需要再解析 body。后续每次 cluster/key 级尝试都复用 `bodyModel` 变量,并随 `aiClusterInvoke()` → `doSingleAIForward()` 调用链透传: -1. `attempt.Model` 覆盖(若非空); -2. `cluster.AIConf.MatchPrefix` 前缀裁剪(若 `StripPrefix=true`); -3. `cluster.AIConf.ModelMapping` 模型映射(若配置)。 +```go +// bodyModel tracks the model currently in the request body. +// aiMeta.ClientModel was already extracted from the request body before +// entering the reverse proxy, so we can use it as the initial value without +// parsing the body again. Only this loop modifies the body model, so we +// keep the value in a variable instead of parsing the body on every attempt. +bodyModel := aiMeta.ClientModel + +for i, attempt := range attempts { + ... + res, action, lastCluster, invokeErr, bodyModel = + p.aiClusterInvoke(srv, serverConf, basicReq, rw, attempt, aiMeta, bodyModel) + ... +} +``` -计算得到最终 `model` 后,**最多调用一次** `condition.ReqBodyJsonSet()` 写入请求体,避免重复 JSON 解析/序列化: +`doSingleAIForward()` 中按 7.1 的顺序计算 `targetModel`,无条件更新 `aiMeta.TargetModel`,然后只在 `targetModel` 与当前 body 中的 model(`bodyModel`)不一致时才写入请求体: ```go -if model != aiMeta.ClientModel { - if err := condition.ReqBodyJsonSet(basicReq, "model", model); err != nil { +// Calculate the final model in order: route target/fallback override -> +// provider/model prefix stripping -> cluster model mapping. +targetModel := aiMeta.ClientModel + +if attempt.Model != "" { + targetModel = attempt.Model +} + +if cluster.AIConf != nil && cluster.AIConf.StripPrefix && cluster.AIConf.MatchPrefix != "" { + if stripped, ok := stripProviderPrefix(targetModel, cluster.AIConf.MatchPrefix); ok { + targetModel = stripped + } +} + +if cluster.AIConf != nil && cluster.AIConf.ModelMapping != nil && targetModel != "" { + if mapped, ok := (*cluster.AIConf.ModelMapping)[targetModel]; ok { + targetModel = mapped + } +} + +aiMeta.TargetModel = targetModel + +newBodyModel = bodyModel +if targetModel != bodyModel { + if err := condition.ReqBodyJsonSet(basicReq, "model", targetModel); err != nil { log.Logger.Warn("Failed to set model in request body: %s", err) } else { // outreq body already changed, need reset Content-Length @@ -646,12 +692,18 @@ if model != aiMeta.ClientModel { basicReq.HttpRequest.ContentLength = -1 basicReq.HttpRequest.Header.Del("Content-Length") } - aiMeta.TargetModel = model + newBodyModel = targetModel } } ``` -以避免 `Content-Length` 与实际 body 长度不一致,并保证 fallback/retry 时能从原始请求体重新构造 `OutRequest`。 +关键点: + +- 在模型名计算之前,`doSingleAIForward()` 会先校验 cluster 配置的 `ModelProtocols` 是否支持当前请求的 `AuthStyle`;若不匹配则直接返回错误响应,不会进入后续的 model 改写逻辑。 +- 每个 cluster 尝试都从 `ClientModel` 重新计算,避免 fallback 时继承上一次改写后的 model。 +- `aiMeta.TargetModel` 无条件更新为当前 cluster 尝试的最终目标模型,无论 body 是否需要改写。 +- `bodyModel` 缓存请求体中实际 model;`ReqBodyJsonSet` 只在真正发生变化时调用,同 cluster 的 key 级重试不会重复改写。 +- `Content-Length` 在 body 改写后统一重置,避免与实际 body 长度不一致。 ## 8. Fallback 机制 diff --git a/docs/zh_cn/sys_design/provider_model_prefix_routing.md b/docs/zh_cn/sys_design/provider_model_prefix_routing.md index 30fb59c4c..4685546f9 100644 --- a/docs/zh_cn/sys_design/provider_model_prefix_routing.md +++ b/docs/zh_cn/sys_design/provider_model_prefix_routing.md @@ -103,39 +103,60 @@ func AIConfCheck(conf *AIConf) error { **文件:** `bfe/bfe_server/reverseproxy.go` -在 `doSingleAIForward()` 函数中,按 **route target model override** → **provider 前缀裁剪** → **cluster `ModelMapping`** 的顺序计算最终模型名,然后统一写入请求体: +`ServeHTTPForAI()` 在 cluster 级 fallback 循环开始前,从 `aiMeta.ClientModel` 初始化 `bodyModel`。`aiMeta.ClientModel` 在进入反向代理前已从请求体中解析出来,因此这里不需要再解析 body。后续每次 cluster/key 级尝试都复用 `bodyModel` 变量,不再重复解析请求体: + +```go +// bodyModel tracks the model currently in the request body. +// aiMeta.ClientModel was already extracted from the request body before +// entering the reverse proxy, so we can use it as the initial value without +// parsing the body again. Only this loop modifies the body model, so we +// keep the value in a variable instead of parsing the body on every attempt. +bodyModel = aiMeta.ClientModel + +for i, attempt := range attempts { + ... + res, action, lastCluster, invokeErr, bodyModel = + p.aiClusterInvoke(srv, serverConf, basicReq, rw, attempt, aiMeta, bodyModel) + ... +} +``` + +在 `doSingleAIForward()` 函数中,按 **route target model override** → **provider 前缀裁剪** → **cluster `ModelMapping`** 的顺序计算最终模型名,然后只在最终值与当前 body 中的 model 不一致时才写入请求体: ```go // Calculate the final model in order: route target/fallback override -> // provider/model prefix stripping -> cluster model mapping. Then write it -// to the request body at most once. -model := aiMeta.ClientModel -if aiMeta.TargetModel != "" { - model = aiMeta.TargetModel -} +// to the request body only when it differs from the current body value. +// Always start from ClientModel for every cluster attempt, so fallbacks +// recompute the target model from the original client value. +targetModel := aiMeta.ClientModel // apply model override from ai route target/fallback if attempt.Model != "" { - model = attempt.Model + targetModel = attempt.Model } // 按 cluster AIConf 裁剪 provider 前缀 if cluster.AIConf != nil && cluster.AIConf.StripPrefix && cluster.AIConf.MatchPrefix != "" { - if stripped, ok := stripProviderPrefix(model, cluster.AIConf.MatchPrefix); ok { - model = stripped + if stripped, ok := stripProviderPrefix(targetModel, cluster.AIConf.MatchPrefix); ok { + targetModel = stripped } } // apply cluster model mapping -if cluster.AIConf != nil && cluster.AIConf.ModelMapping != nil && model != "" { - if newModel, ok := (*cluster.AIConf.ModelMapping)[model]; ok { - model = newModel +if cluster.AIConf != nil && cluster.AIConf.ModelMapping != nil && targetModel != "" { + if mapped, ok := (*cluster.AIConf.ModelMapping)[targetModel]; ok { + targetModel = mapped } } -// 统一写入请求体(最多一次) -if model != aiMeta.ClientModel { - if err := condition.ReqBodyJsonSet(basicReq, "model", model); err != nil { +// 记录本次 cluster 尝试最终要使用的目标模型名 +aiMeta.TargetModel = targetModel + +// bodyModel 由调用方缓存;只有真正需要改写 body 时才调用 ReqBodyJsonSet +newBodyModel = bodyModel +if targetModel != bodyModel { + if err := condition.ReqBodyJsonSet(basicReq, "model", targetModel); err != nil { log.Logger.Warn("Failed to set model in request body: %s", err) } else { // outreq body already changed, need reset Content-Length @@ -149,17 +170,20 @@ if model != aiMeta.ClientModel { basicReq.HttpRequest.ContentLength = -1 basicReq.HttpRequest.Header.Del("Content-Length") } - aiMeta.TargetModel = model + newBodyModel = targetModel } } ``` 关键细节: +- 在模型名计算之前,`doSingleAIForward()` 会先校验 cluster 配置的 `ModelProtocols` 是否支持当前请求的 `AuthStyle`;若不匹配则直接返回错误响应,不会进入后续的 model 改写逻辑。 - 只裁剪 **第一段** provider 前缀。例如 `openrouter/anthropic/claude-xxx` → `anthropic/claude-xxx`。 - 裁剪后再执行 `ModelMapping`,因此 `ModelMapping` 的 key 应使用裁剪后的模型名。 - 如果 `attempt.Model` 已覆盖目标模型,则基于覆盖后的模型进行前缀裁剪。 - 裁剪后内容为空时(如 `openrouter/`),跳过裁剪并记录 warn,避免下发空 model。 +- **每个 cluster 尝试都从 `ClientModel` 开始计算**,`aiMeta.TargetModel` 只用于记录当前 cluster 尝试的最终结果,不作为下一次 cluster 尝试的输入,避免 fallback 时继承上一次改写后的 model。 +- `bodyModel` 缓存当前请求体中实际 model;只有 `targetModel != bodyModel` 时才调用 `ReqBodyJsonSet`,同 cluster 的 key 级重试不会重复改写。 ### 4.2 `ClientModel` 与 `TargetModel` 的区分 @@ -179,15 +203,28 @@ if err == nil || len(model) > 0 { `ClientModel` 保持原始值不变,`TargetModel` 在 `doSingleAIForward()` 中随裁剪/映射更新。 -## 5. 重试安全性分析 +## 5. 重试与 fallback 正确性分析 + +### 5.1 cluster 级 fallback + +每次 fallback 到新 cluster 时,必须重新按当前 cluster 的配置计算目标模型,而不是继承上一个 cluster 改写后的结果。当前实现通过两点保证这一点: + +1. **`doSingleAIForward()` 中 `targetModel` 始终从 `aiMeta.ClientModel` 开始计算**,不再读取 `aiMeta.TargetModel`。因此即使上一次尝试已经把 `TargetModel` 改写为其他值,下一次 cluster 尝试仍会基于原始客户端 model 重新应用 `attempt.Model`、strip、mapping。 +2. **`bodyModel` 缓存请求体中实际 model**:`ServeHTTPForAI()` 进入 fallback 循环前从 `aiMeta.ClientModel` 初始化 `bodyModel`(`ClientModel` 在进入反向代理前已从请求体中解析)。后续每次尝试都通过 `aiClusterInvoke` → `doSingleAIForward` 透传该变量。`doSingleAIForward` 只有在 `targetModel != bodyModel` 时才调用 `ReqBodyJsonSet`,改写成功后同步更新 `newBodyModel = targetModel`。因此: + - 如果新 cluster 算出的 targetModel 与 body 中当前 model 相同,不会重复改写; + - 如果新 cluster 算出的 targetModel 与 body 中当前 model 不同(例如 fallback 后需要改回 ClientModel,或换了一个 target model),会正确改写。 + +注意:BFE 的 `bytes_body.Rewind()` 只是将当前 buffer 重新设为读取起点,不会恢复到客户端原始 body。因此 `bodyModel` 缓存是必要的,不能假设 body 在 fallback 后仍等于 `ClientModel`。 + +### 5.2 key 级重试 -修改 `aiMeta.TargetModel` 不会影响下一次重试的正确性,原因如下: +同一个 cluster 内因为 429/5xx 等原因进行 key 级重试时,配置不变,`targetModel` 计算结果也不变。由于第一次重试已经改写 body 并把 `newBodyModel` 设为 targetModel,后续重试传入的 `bodyModel` 已经等于 targetModel,`ReqBodyJsonSet` 不会再被调用,避免重复 JSON 解析/序列化。 -1. **请求体不会被回滚为原始请求体**:BFE 的 `bytes_body.Rewind()` 只是将当前 buffer 重新设为读取起点,不会恢复到客户端原始 body。因此已裁剪的请求内容在重试时会被保留。 -2. **`HasPrefix` 保护避免重复裁剪**:第一次裁剪后 `TargetModel` 已不含该前缀,下次进入 `doSingleAIForward()` 时不会再命中 `MatchPrefix`,不会二次裁剪。 -3. **`ClientModel` 始终保持原始值**:日志、计费等地方仍能看到客户端原始模型名。 +### 5.3 状态一致性 -下文给出关键结论。 +- `ClientModel` 始终保持客户端原始值,用于日志、计费等场景。 +- `aiMeta.TargetModel` 记录**当前 cluster 尝试**最终计算出的目标模型,每次 `doSingleAIForward()` 都会无条件更新,无论 body 是否需要改写。 +- 请求体中的 `model` 字段与 `bodyModel` 缓存保持同步,只在真正变化时写入。 ## 6. 对 Token 鉴权和限流的影响 diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/sc08_ai_model_fallback_recompute_test.go b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/sc08_ai_model_fallback_recompute_test.go new file mode 100644 index 000000000..c6ac76a51 --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/sc08_ai_model_fallback_recompute_test.go @@ -0,0 +1,228 @@ +// Copyright (c) 2026 The BFE Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sc08 + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/bfenetworks/bfe/bfe_config/bfe_cluster_conf/cluster_conf" + "github.com/bfenetworks/bfe/tests/integration/common" +) + +const ( + apiHost = "api.example.org" + apiPath = "/v1/chat/completions" + apiKey = "ak_user_a" + + clusterPrimary = "cluster_openrouter" + clusterFallback = "cluster_fallback" + clusterDefault = "cluster_default" +) + +type testEnv struct { + t *testing.T + processEnv *common.ProcessEnv + backends map[string]*common.MockBackend + bfePort int + stopBFE func() +} + +func newTestEnv(t *testing.T, primaryStatus, fallbackStatus, defaultStatus int, + primaryAIConf, fallbackAIConf *cluster_conf.AIConf) *testEnv { + e := &testEnv{ + t: t, + backends: make(map[string]*common.MockBackend), + } + + e.backends[clusterPrimary] = common.NewMockBackend(clusterPrimary, primaryStatus, `{"ok":true}`) + e.backends[clusterFallback] = common.NewMockBackend(clusterFallback, fallbackStatus, `{"ok":true}`) + e.backends[clusterDefault] = common.NewMockBackend(clusterDefault, defaultStatus, `{"ok":true}`) + + e.processEnv = common.NewProcessEnv(t) + e.processEnv.Build() + + confDir := filepath.Join(e.processEnv.WorkDir(), "conf") + logDir := filepath.Join(e.processEnv.WorkDir(), "log") + + aiConfs := map[string]*cluster_conf.AIConf{} + if primaryAIConf != nil { + aiConfs[clusterPrimary] = primaryAIConf + } + if fallbackAIConf != nil { + aiConfs[clusterFallback] = fallbackAIConf + } + + builder := &common.BFEConfigBuilder{ + TemplateDir: "testdata", + TargetConfDir: confDir, + Backends: e.backends, + AIConfs: aiConfs, + } + if err := builder.Build(); err != nil { + t.Fatalf("build bfe config failed: %v", err) + } + + e.bfePort, _, e.stopBFE = e.processEnv.StartBFE(confDir, logDir) + return e +} + +func (e *testEnv) Close() { + if e.stopBFE != nil { + e.stopBFE() + } + for _, b := range e.backends { + b.Close() + } +} + +func (e *testEnv) logBFEException() { + data, err := os.ReadFile(filepath.Join(e.processEnv.WorkDir(), "log", "exception.log")) + if err == nil && len(data) > 0 { + e.t.Logf("bfe exception log:\n%s", string(data)) + } +} + +func (e *testEnv) sendRequest(body []byte) (*http.Response, string, error) { + url := fmt.Sprintf("http://127.0.0.1:%d%s", e.bfePort, apiPath) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, "", err + } + req.Host = apiHost + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + return resp, string(respBody), nil +} + +func primaryAIConf() *cluster_conf.AIConf { + mappings := map[string]string{ + "modelA": "mapped-primary-model", + } + return &cluster_conf.AIConf{ + Type: 0, + MatchPrefix: "openrouter/", + StripPrefix: true, + ModelMapping: &mappings, + } +} + +func fallbackAIConfStripClientPrefix() *cluster_conf.AIConf { + return &cluster_conf.AIConf{ + Type: 0, + MatchPrefix: "clientprefix/", + StripPrefix: true, + } +} + +// TestTC01 verifies that when the primary cluster fails, the fallback cluster +// recomputes the target model from ClientModel instead of inheriting the +// primary cluster's mapped model. +func TestTC01_FallbackRecomputeFromClientModel(t *testing.T) { + // primary returns 503 to trigger fallback; fallback returns 200 + e := newTestEnv(t, http.StatusServiceUnavailable, http.StatusOK, http.StatusOK, + primaryAIConf(), nil) + defer e.Close() + + clientModel := "clientprefix/mymodel" + body := []byte(`{"model":"` + clientModel + `","messages":[{"role":"user","content":"hello"}]}`) + resp, respBody, err := e.sendRequest(body) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, respBody) + } + + if e.backends[clusterPrimary].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterPrimary, e.backends[clusterPrimary].Hits()) + } + if e.backends[clusterFallback].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterFallback, e.backends[clusterFallback].Hits()) + } + if e.backends[clusterDefault].Hits() != 0 { + t.Fatalf("expected 0 hit on %s, got %d", clusterDefault, e.backends[clusterDefault].Hits()) + } + + primaryModels := e.backends[clusterPrimary].Models() + if len(primaryModels) != 1 || primaryModels[0] != "mapped-primary-model" { + t.Fatalf("expected primary model 'mapped-primary-model', got %v", primaryModels) + } + + fallbackModels := e.backends[clusterFallback].Models() + if len(fallbackModels) != 1 || fallbackModels[0] != clientModel { + // If the fallback inherited the primary's TargetModel, it would send + // "mapped-primary-model" instead of the original client model. + t.Fatalf("expected fallback model '%s' (recomputed from ClientModel), got %v", clientModel, fallbackModels) + } +} + +// TestTC02 verifies that when the fallback cluster has its own strip +// configuration, it recomputes from ClientModel and applies the fallback +// cluster's own transformation, not the primary's mapped model. +func TestTC02_FallbackOwnStripConfig(t *testing.T) { + // primary returns 503 to trigger fallback; fallback returns 200 + e := newTestEnv(t, http.StatusServiceUnavailable, http.StatusOK, http.StatusOK, + primaryAIConf(), fallbackAIConfStripClientPrefix()) + defer e.Close() + + clientModel := "clientprefix/mymodel" + body := []byte(`{"model":"` + clientModel + `","messages":[{"role":"user","content":"hello"}]}`) + resp, respBody, err := e.sendRequest(body) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, respBody) + } + + if e.backends[clusterPrimary].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterPrimary, e.backends[clusterPrimary].Hits()) + } + if e.backends[clusterFallback].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterFallback, e.backends[clusterFallback].Hits()) + } + + primaryModels := e.backends[clusterPrimary].Models() + if len(primaryModels) != 1 || primaryModels[0] != "mapped-primary-model" { + t.Fatalf("expected primary model 'mapped-primary-model', got %v", primaryModels) + } + + fallbackModels := e.backends[clusterFallback].Models() + // The fallback cluster strips the "clientprefix/" prefix from the + // recomputed ClientModel. + if len(fallbackModels) != 1 || fallbackModels[0] != "mymodel" { + t.Fatalf("expected fallback model 'mymodel', got %v", fallbackModels) + } +} diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/bfe.conf b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/bfe.conf new file mode 100644 index 000000000..5a232930e --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/bfe.conf @@ -0,0 +1,36 @@ +[server] +httpPort = 18080 +httpsPort = 18443 +monitorPort = 18081 +httpAddr = "127.0.0.1" +httpsAddr = "127.0.0.1" +monitorAddr = "127.0.0.1" +MonitorEnabled = true +maxCpus = 1 + +TlsHandshakeTimeout = 30 +ClientReadTimeout = 5 +ClientWriteTimeout = 5 +KeepAliveEnabled = true +GracefulShutdownTimeout = 10 + +EnableAiGateway = true + +accessibleBodySize = 4194304 + +# max total bytes of all active bytes_body buffers (0 means unlimited) +totalBodyBufferSize = 0 + +Modules = mod_ai_route + +hostRuleConf = server_data_conf/host_rule.data +routeRuleConf = server_data_conf/route_rule.data +vipRuleConf = server_data_conf/vip_rule.data + +clusterTableConf = cluster_conf/cluster_table.data +gslbConf = cluster_conf/gslb.data +clusterConf = cluster_conf/cluster_conf.data +NameConf = + +maxHeaderUriBytes = 8096 +maxHeaderBytes = 8096 diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/cluster_conf/gslb.data b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/cluster_conf/gslb.data new file mode 100644 index 000000000..b2fa8648a --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/cluster_conf/gslb.data @@ -0,0 +1,18 @@ +{ + "clusters": { + "cluster_openrouter": { + "GSLB_BLACKHOLE": 0, + "sub_openrouter": 100 + }, + "cluster_fallback": { + "GSLB_BLACKHOLE": 0, + "sub_fallback": 100 + }, + "cluster_default": { + "GSLB_BLACKHOLE": 0, + "sub_default": 100 + } + }, + "hostname": "gslb-test", + "ts": "20260720150000" +} diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/mod_ai_route/ai_route.data b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/mod_ai_route/ai_route.data new file mode 100644 index 000000000..8db39a4b2 --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/mod_ai_route/ai_route.data @@ -0,0 +1,44 @@ +{ + "Version": "20260720150000", + "route_rules": { + "apikey_ak_user_a": { + "type": "apikey", + "owner": "ak_user_a", + "rules": [ + { + "name": "user_a-openrouter", + "Cond": "req_body_json_prefix_in(\"model\", \"clientprefix/\", false)", + "targets": [ + { + "ClusterName": "cluster_openrouter", + "Model": "openrouter/modelA", + "Weight": 100 + } + ], + "fallbacks": [ + { + "ClusterName": "cluster_fallback", + "Model": "" + } + ] + }, + { + "name": "user_a-default", + "Cond": "default_t()", + "targets": [ + { + "ClusterName": "cluster_default", + "Model": "", + "Weight": 100 + } + ] + } + ] + } + }, + "ApikeyRouteTableBindings": { + "ak_user_a": [ + "apikey_ak_user_a" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/mod_ai_route/mod_ai_route.conf b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/mod_ai_route/mod_ai_route.conf new file mode 100644 index 000000000..f250013bb --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/mod_ai_route/mod_ai_route.conf @@ -0,0 +1,5 @@ +[basic] +RouteRulePath = mod_ai_route/ai_route.data + +[log] +OpenDebug = true diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/server_data_conf/host_rule.data b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/server_data_conf/host_rule.data new file mode 100644 index 000000000..ae991307e --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/server_data_conf/host_rule.data @@ -0,0 +1,13 @@ +{ + "Version": "20260720150000", + "Hosts": { + "ai_product": [ + "api.example.org" + ] + }, + "HostTags": { + "ai_product": [ + "ai_product" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/server_data_conf/route_rule.data b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/server_data_conf/route_rule.data new file mode 100644 index 000000000..f15b5b168 --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/server_data_conf/route_rule.data @@ -0,0 +1,12 @@ +{ + "Version": "20260720150000", + "BasicRule": { + "ai_product": [ + { + "Hostname": ["*"], + "Path": ["*"], + "ClusterName": "cluster_default" + } + ] + } +} diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/server_data_conf/vip_rule.data b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/server_data_conf/vip_rule.data new file mode 100644 index 000000000..6fe22f03a --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/server_data_conf/vip_rule.data @@ -0,0 +1,8 @@ +{ + "Version": "20260720150000", + "Vips": { + "ai_vip": [ + "127.0.0.1" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/bfe_i_ca.crt b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/bfe_i_ca.crt new file mode 100644 index 000000000..f1e78f73c --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/bfe_i_ca.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDwDCCAqigAwIBAgIBCzANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzExMDMx +NDAxNDZaFw0zNzA3MTIxNDAxNDZaMIGQMQswCQYDVQQGEwJjbjEQMA4GA1UECAwH +YmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsMC3lpbmdmZWkt +ZGV2MRgwFgYDVQQDDA95aW5nZmVpLWRldi1pY2ExKTAnBgkqhkiG9w0BCQEWGmxp +YW5nY2h1YW5AeWYtbmV0d29ya3MuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA6eFuWgoknixrRO9NCX4jAKyLtcAOWVJqVN2yX7CxjZjLyPurjvTZ +W73NYUbrPAN4AB5gY6UAPzuiEOSopVVIZ0OschK0cJldu9vZ0mZObBOsovuFcLQq +dgNSJ5slJSk7tCgD2EnCB3GYPG4D+uIKYd0c49wzTWWv4bjDwpgnf0LQbFpy7GhN +7D59zFH4qgOK/IQ5vaTMGyvIvtWR5/1Gvc9MLpGopTgi0DiNLed4UwDYrod5kysl +q3UcB5puONHQISOVoD3uRxo7wdsmVsHUfW7YfAWkhi6ec8mx9fy8IyE6f7GlXnuV +ysNccwqyEotL5bOXPJCqwUCL1v+iKTMPWwIDAQABo1AwTjAdBgNVHQ4EFgQUCjvC +vDVr8QXh9/hMI5xBgNvoSFgwHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/oqHdW +1D0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAhIggK/6JK4N7+HZW +VoTemwRpilugZZyKrcVAHbiiwUfXVQVuI64vc+yHWMSFRD1mykHkKBFzxEoDabMl +ASBtUJNt4b4zEL9V7k295vmAOp2IdLUQxlgeKWqABm4DGX96tGR9nKQTcn1ZeAxx +NyQFV1aj+dnNcF1iFFNF6t0bRrOEZ/aRSiu1bWp3Dj1JYTjXbyyplh3Ktb8lv4lt +EmvCXjo/l4TgQC9233kcTkXcq1swppzkkXfhB0NVuf9DE3C2xAWX0b2FiIEqnAhl +Bx0Cn3RrX7PZ6qrdRL6oBKvGJV6DP8BlF4LXiuD1VN/waQFJha57KQ78ZYYIXxQg +WMjWgA== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/bfe_r_ca.crt b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/bfe_r_ca.crt new file mode 100644 index 000000000..2a6db1e49 --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/bfe_r_ca.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDkzCCAnugAwIBAgIBCjANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzExMDMx +MzQ5NTVaFw0zNzA3MTIxMzQ5NTVaMGQxCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEUMBIGA1UECwwLeWluZ2ZlaS1k +ZXYxFzAVBgNVBAMMDnlpbmdmZWktZGV2LWNhMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAvNA3HrsMjBcXrMIIhGVWsurIA1F9jxKeA7dh06H00Vt4inVV +SUvNFrTqgPRhLkAhGRMPxrjVRgJ5bbFqqXIuPIpUFBhUsWXIDH+oVXQl9jsxAXaG +gZ0lTO/uYR9qyrS1rj9nyNPwRf59Al/VlsQL71cNQ/T/agJ4PfvPfULTPLOsqclJ +hj0IgXmDj464dqcdG3ZdfXpfhNF6ab+8YjpwafTRmY+LoV8qjUwsYeJMcW4N8pxJ +8F2ktZj9J6uWepNGj+87ZeXg9XquzC62ASIFzPjoE1WN//Q518EqizxhuLGBDpK3 +6sEYUK4kHYUL4gZKFPlKTPXIl0ZsJIM5PsBMKwIDAQABo1AwTjAdBgNVHQ4EFgQU +DZloT8VhVbysD819oG/oqHdW1D0wHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/o +qHdW1D0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEALgB+IcN3UD4T ++4g8jaOyOiSUpUVdYqW+3go5DppqnvlGNq99N2cXKqssVPa/T9TikEcBEicFa8zU +bwlx6TEte+MkWfWdQxFR1EuI1FgKr3ps6ZBRr7MPpnYmI7K9aK372K9n7WrQhbmP +s7ult8bWB1/t6o3R7B9ChNkWT+7DPD4+FvB1GMJSGPno7cdnvDkevBOuC2DnQl3M ++ADFAge1Lo8wKBy6gYkNFd2BfarHGvRC5Qmmrme+RIpWZnvux1+lfXIInnfSTJRM +uAo/ePkoNsM3qQll6uEdhDxOMx8Pq94bCkM3DtI3ObuxWXjKCgUm/n9yetUvPj4U +iYhRUqHpUg== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_bfe_dev.crt b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_bfe_dev.crt new file mode 100644 index 000000000..2164fb602 --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_bfe_dev.crt @@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 18 (0x12) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=yingfei-dev-ca + Validity + Not Before: Jan 31 02:55:32 2024 GMT + Not After : Jan 28 02:55:32 2034 GMT + Subject: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=bfe-dev/emailAddress=dev@example.org + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:b2:7f:c1:0c:cd:49:43:d9:99:78:15:4c:5a:52: + a9:a7:bf:d5:eb:92:71:43:e1:37:e5:29:1b:68:f4: + 6f:4c:ea:fa:a4:8a:7c:29:01:2a:fa:7c:81:5b:c8: + eb:a1:40:94:b7:2b:82:e2:00:08:36:84:f0:b7:d2: + 5a:1e:56:97:aa:36:ff:4d:07:49:2d:fe:25:3a:f9: + e9:f1:ad:4e:6e:21:97:a9:f9:a2:a5:ac:82:23:0a: + d2:e0:97:cd:2f:14:b1:f0:8a:a8:e6:c5:97:45:94: + f8:8e:3f:96:66:5b:0b:8c:7c:07:61:18:44:92:f7: + 23:5a:c2:4b:88:58:59:5d:ca:5c:0d:6e:dd:ff:18: + 59:65:df:95:99:e3:3a:36:48:1f:3f:3a:e6:ce:85: + f3:0b:04:5e:92:ed:6f:8e:74:92:e4:37:46:da:5f: + 17:62:9c:82:40:06:fb:29:f8:55:f2:ba:23:75:ca: + 64:c0:45:03:12:bd:f5:17:15:7e:47:d5:bd:30:f2: + 99:ca:6b:e3:07:b0:ae:44:89:1e:10:26:ea:75:df: + 6f:07:b6:47:76:54:47:4f:6c:f6:68:fe:a8:cf:22: + 20:73:e8:19:55:8a:fe:f5:78:e8:51:88:52:80:1f: + 79:d4:c5:ae:8f:d2:2b:f6:41:01:42:01:cf:98:c2: + c9:25 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + EE:E8:68:42:DF:B1:F0:EF:6F:47:51:BD:D4:94:60:1F:05:85:A1:03 + X509v3 Authority Key Identifier: + keyid:0D:99:68:4F:C5:61:55:BC:AC:0F:CD:7D:A0:6F:E8:A8:77:56:D4:3D + + X509v3 Extended Key Usage: + TLS Web Client Authentication + Signature Algorithm: sha256WithRSAEncryption + a9:a6:26:8e:42:61:15:22:ee:fc:b5:e1:e4:6b:dd:ac:f5:15: + 11:39:10:9a:ca:6f:85:fd:cb:90:1c:b2:4f:fe:29:de:b0:e7: + 73:e2:f7:5e:63:8c:7f:c1:7e:75:2e:c9:9d:e9:c2:45:75:f3: + 27:ba:82:94:de:7f:6c:87:0c:5c:71:af:0f:14:00:68:35:f7: + 5a:4a:ff:f5:ef:35:dd:50:72:76:f0:6f:b6:7b:42:33:07:b4: + 24:44:0a:fd:9d:61:9e:44:e8:88:0f:02:76:c6:90:3f:9d:1b: + d8:3b:64:25:2a:a3:39:78:38:bd:20:89:4a:9c:bd:68:38:18: + 4c:cb:20:3a:9b:5b:5f:58:52:86:73:de:85:fe:d6:a1:c6:a7: + 86:b0:96:4b:fa:28:04:ad:5d:85:e8:a1:fc:ca:0f:3c:be:5c: + 90:7e:3e:84:ae:67:ee:9a:72:71:3c:b2:80:45:82:fc:7e:58: + 74:99:42:c5:c3:8a:4a:eb:e1:8b:d5:84:ce:25:aa:a1:75:79: + 94:66:ae:ee:df:30:15:0b:b5:c5:b1:2c:d5:0a:54:78:b6:2e: + 67:29:81:41:f6:16:49:31:96:e7:41:e1:99:6b:27:57:bb:7d: + 76:eb:e4:d5:59:aa:a2:5c:bd:1c:18:2a:fa:9d:28:1a:0b:b6: + bf:7d:58:1a +-----BEGIN CERTIFICATE----- +MIID7jCCAtagAwIBAgIBEjANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yNDAxMzEw +MjU1MzJaFw0zNDAxMjgwMjU1MzJaMH0xCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEUMBIGA1UECwwLeWluZ2ZlaS1k +ZXYxEDAOBgNVBAMMB2JmZS1kZXYxHjAcBgkqhkiG9w0BCQEWD2RldkBleGFtcGxl +Lm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ/wQzNSUPZmXgV +TFpSqae/1euScUPhN+UpG2j0b0zq+qSKfCkBKvp8gVvI66FAlLcrguIACDaE8LfS +Wh5Wl6o2/00HSS3+JTr56fGtTm4hl6n5oqWsgiMK0uCXzS8UsfCKqObFl0WU+I4/ +lmZbC4x8B2EYRJL3I1rCS4hYWV3KXA1u3f8YWWXflZnjOjZIHz865s6F8wsEXpLt +b450kuQ3RtpfF2KcgkAG+yn4VfK6I3XKZMBFAxK99RcVfkfVvTDymcpr4wewrkSJ +HhAm6nXfbwe2R3ZUR09s9mj+qM8iIHPoGVWK/vV46FGIUoAfedTFro/SK/ZBAUIB +z5jCySUCAwEAAaOBkTCBjjAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVu +U1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU7uhoQt+x8O9vR1G9 +1JRgHwWFoQMwHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/oqHdW1D0wEwYDVR0l +BAwwCgYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADggEBAKmmJo5CYRUi7vy14eRr +3az1FRE5EJrKb4X9y5Acsk/+Kd6w53Pi915jjH/BfnUuyZ3pwkV18ye6gpTef2yH +DFxxrw8UAGg191pK//XvNd1Qcnbwb7Z7QjMHtCRECv2dYZ5E6IgPAnbGkD+dG9g7 +ZCUqozl4OL0giUqcvWg4GEzLIDqbW19YUoZz3oX+1qHGp4awlkv6KAStXYXoofzK +Dzy+XJB+PoSuZ+6acnE8soBFgvx+WHSZQsXDikrr4YvVhM4lqqF1eZRmru7fMBUL +tcWxLNUKVHi2LmcpgUH2FkkxludB4ZlrJ1e7fXbr5NVZqqJcvRwYKvqdKBoLtr99 +WBo= +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem new file mode 100644 index 000000000..764aea3ad --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAsn/BDM1JQ9mZeBVMWlKpp7/V65JxQ+E35SkbaPRvTOr6pIp8 +KQEq+nyBW8jroUCUtyuC4gAINoTwt9JaHlaXqjb/TQdJLf4lOvnp8a1ObiGXqfmi +payCIwrS4JfNLxSx8Iqo5sWXRZT4jj+WZlsLjHwHYRhEkvcjWsJLiFhZXcpcDW7d +/xhZZd+VmeM6NkgfPzrmzoXzCwReku1vjnSS5DdG2l8XYpyCQAb7KfhV8rojdcpk +wEUDEr31FxV+R9W9MPKZymvjB7CuRIkeECbqdd9vB7ZHdlRHT2z2aP6ozyIgc+gZ +VYr+9XjoUYhSgB951MWuj9Ir9kEBQgHPmMLJJQIDAQABAoIBAFAKHzupVb/58+o3 +yqv5wx94Uuk2GlnwxIqaezL94GaiO0/K1U/huS7m426P0rDU75qPBTpn/0bLJ9GV +nllaRNnLnYEh0juwaWtfovp+1ttlbseGK9uUVip2cQbKqvQAmKWe14vbcDCAU1Ad +zUgKbUxKVVjBdAZekVjiJNJ3o2L9WPhf/uQo7A1XAJh2DajlTbgvrDM73W+47QuU +X4OHU0FMio6bxupu3OWl1bMrnKhuC4qczZWf2nOpcVQa89rtopuP4ENLJuWkbeGk +YQpNilEclnAa/Noumt/j/6GKC1EEHFsH2CNRRIazcZrsFhkSKc4pn1Y/WI3vj8kZ ++RYnJsUCgYEA6gr0x8suwRTAmVpoPk4XyP1x+eInG7onV5RjgsUtgruYd9naxRHg +2p8PHcv32pDs51Fa+4RldyMd/jec1SscRF5/+VOP9qeoRnaDqUu+uASgEO3OBxbP +JcWovyxRHIQxbYCQtqIr9bdzXw55MBLZou/sBUVTAkrIyPVyjWqZlV8CgYEAwz7L +YyYN615TsrzZKURxMjj94Nmob/NldSLRXaR3Ax7/ABtEOA685cwQxq7ONdkJTMIA +uR8u2GHZSzGiWnehuF6Zp7Xs71a57eFbs3ueZvvEba4Dff7hl7Y4tTlwrKndKjvP +J/5a2Ol8siQcRWAXHOdzggEHMSZ/sB4hWswly/sCgYEAhLRBpyemEwTZUBrbELjm +86gBgFajJi2fMSGKaxOygnYsNYjpauSAQnX99D87Aks6iM6wb/zaK3tV/lc6LgSL +uph6p7yh3JGj8JAyh0PTmDPHLtIoCAz+18QDsqJGO40ZGaXUaDn8Aw9J85QZUxDd +Jm4zvalZL+uHfarukRDolLECgYBUiupS4nWAh3XCnZeDEQna72avaFBROZmjIRJ7 +c+28wj009JmTlH4jGzvgbG0KUBKA1Div8Fq+g5AtyS498jNqvDvYrSQNdwZHhR/K +Fis++KHTxFfqxOU2Zkcj4d1yRpNn6EIJVVBNQL0n/g7n03XupCIWFw/gLoV343QZ +9vAe5QKBgA8ml59z1w3eUooc0yGfLhihXqCmM3IU006bFbODA30fBU4QKrHO8+Yx +Xbz9bi/1QLagLG6FzYQAkOjBlEBt5XLayYvwSb8xvWsm5A3vzTAbMFDOsDPEmRoH +dWtQccJcygOuK+PZtoZnNoJciNO5c9dZWD3xtIiURmX/kVtWrf6N +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_san_example.org.crt b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_san_example.org.crt new file mode 100644 index 000000000..78f3892b4 --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_san_example.org.crt @@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 15 (0xf) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=yingfei-dev-ca + Validity + Not Before: Dec 7 09:57:42 2023 GMT + Not After : Dec 4 09:57:42 2033 GMT + Subject: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei, CN=dev-test + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:d6:47:b0:17:69:91:4c:d0:66:c9:25:2e:38:f2: + 84:27:e2:7c:38:cc:04:b9:0c:8e:3d:cc:ef:4b:c5: + 35:2b:c1:82:d8:41:fc:25:c8:24:f2:e0:25:aa:f9: + 76:3c:e2:b2:50:fb:2d:ec:4e:16:d4:c1:da:1e:e3: + 51:9f:9d:c9:72:f9:cb:cc:14:9c:c1:82:c9:76:1f: + 98:dd:0e:b9:8a:20:d6:ab:f2:a6:f9:2f:23:81:f3: + af:e4:47:0c:55:95:94:de:ed:f6:a5:24:ee:32:e7: + 6b:79:e1:42:6f:a4:07:b2:95:1d:f5:a9:8e:60:42: + 70:42:bd:e2:30:18:68:74:52:32:98:a9:81:da:d8: + c6:6f:5e:1d:ce:79:b6:f3:ec:4f:ed:7d:22:57:d2: + 14:d0:fb:f2:50:d4:80:3b:89:ed:77:fd:45:6c:e6: + 52:6b:0a:52:71:ac:59:c8:d5:25:f5:40:03:fb:51: + b0:11:a7:00:79:d9:d8:4f:00:43:96:68:44:29:41: + dc:d2:cc:91:c8:61:95:41:4d:0e:66:5a:b5:15:67: + 3e:8a:6f:29:df:1c:8a:6f:ee:9e:97:9c:9e:69:71: + d3:34:52:75:e9:ea:e7:51:77:23:98:46:ca:47:a2: + d3:d3:97:03:41:4b:e3:33:11:72:2d:af:bf:2b:3e: + b3:51 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + 5A:27:32:9D:E7:36:24:A3:C1:DC:2F:95:80:C5:CF:0C:85:E8:E6:AF + X509v3 Authority Key Identifier: + keyid:0D:99:68:4F:C5:61:55:BC:AC:0F:CD:7D:A0:6F:E8:A8:77:56:D4:3D + + X509v3 Subject Alternative Name: + DNS:example.org, DNS:www.example.org, DNS:example.com, DNS:*.example.com, IP Address:127.0.0.1, IP Address:192.168.0.100 + Signature Algorithm: sha256WithRSAEncryption + 1e:e8:e8:8a:ad:a8:0e:fc:c9:82:00:a1:ab:30:3c:a5:b9:dc: + d6:fb:86:ad:30:52:7f:61:be:90:a6:b8:56:bb:f1:0b:e6:39: + 38:65:09:6b:da:83:f7:65:ff:c4:21:de:b4:9e:8b:bd:1e:1c: + d1:d5:94:b8:18:79:f2:d0:06:51:39:67:13:40:3b:73:5b:cb: + ea:de:c1:19:76:f8:7b:0f:15:51:61:49:fb:98:f7:ea:4f:fc: + c2:fb:a7:f4:3c:48:64:14:79:b5:78:5b:20:10:b5:7a:2d:4c: + 04:51:60:ec:20:10:19:26:5f:e2:fd:32:59:67:e9:3f:48:8d: + f5:52:12:01:81:2c:c0:e5:72:cd:7d:0a:eb:7a:05:df:a0:77: + b9:ba:9a:7d:d1:4b:6a:44:e4:2d:98:af:bd:77:2b:f5:ef:26: + 4b:75:b3:97:d0:3a:bc:07:21:ef:71:92:30:fe:a2:79:e5:56: + d7:7e:c2:f3:57:ab:d7:de:fc:97:ed:20:0c:9a:cb:c5:5d:00: + 3b:61:29:e8:00:d4:39:e0:f2:4e:a4:03:c2:12:52:ff:e7:78: + f9:f7:c0:12:dc:36:a4:05:a2:f0:6b:47:e2:21:3d:a2:e1:a1: + 91:c7:ac:8f:b8:ae:58:65:e0:2b:57:80:eb:77:2d:48:ef:e6: + fb:b9:e1:20 +-----BEGIN CERTIFICATE----- +MIIEBzCCAu+gAwIBAgIBDzANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzEyMDcw +OTU3NDJaFw0zMzEyMDQwOTU3NDJaMFoxCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEQMA4GA1UECwwHeWluZ2ZlaTER +MA8GA1UEAwwIZGV2LXRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQDWR7AXaZFM0GbJJS448oQn4nw4zAS5DI49zO9LxTUrwYLYQfwlyCTy4CWq+XY8 +4rJQ+y3sThbUwdoe41Gfncly+cvMFJzBgsl2H5jdDrmKINar8qb5LyOB86/kRwxV +lZTe7falJO4y52t54UJvpAeylR31qY5gQnBCveIwGGh0UjKYqYHa2MZvXh3Oebbz +7E/tfSJX0hTQ+/JQ1IA7ie13/UVs5lJrClJxrFnI1SX1QAP7UbARpwB52dhPAEOW +aEQpQdzSzJHIYZVBTQ5mWrUVZz6KbynfHIpv7p6XnJ5pcdM0UnXp6udRdyOYRspH +otPTlwNBS+MzEXItr78rPrNRAgMBAAGjgc0wgcowCQYDVR0TBAIwADAsBglghkgB +hvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYDVR0OBBYE +FFonMp3nNiSjwdwvlYDFzwyF6OavMB8GA1UdIwQYMBaAFA2ZaE/FYVW8rA/NfaBv +6Kh3VtQ9ME8GA1UdEQRIMEaCC2V4YW1wbGUub3Jngg93d3cuZXhhbXBsZS5vcmeC +C2V4YW1wbGUuY29tgg0qLmV4YW1wbGUuY29thwR/AAABhwTAqABkMA0GCSqGSIb3 +DQEBCwUAA4IBAQAe6OiKragO/MmCAKGrMDyludzW+4atMFJ/Yb6QprhWu/EL5jk4 +ZQlr2oP3Zf/EId60nou9HhzR1ZS4GHny0AZROWcTQDtzW8vq3sEZdvh7DxVRYUn7 +mPfqT/zC+6f0PEhkFHm1eFsgELV6LUwEUWDsIBAZJl/i/TJZZ+k/SI31UhIBgSzA +5XLNfQrregXfoHe5upp90UtqROQtmK+9dyv17yZLdbOX0Dq8ByHvcZIw/qJ55VbX +fsLzV6vX3vyX7SAMmsvFXQA7YSnoANQ54PJOpAPCElL/53j598AS3DakBaLwa0fi +IT2i4aGRx6yPuK5YZeArV4Drdy1I7+b7ueEg +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem new file mode 100644 index 000000000..393a79825 --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA1kewF2mRTNBmySUuOPKEJ+J8OMwEuQyOPczvS8U1K8GC2EH8 +Jcgk8uAlqvl2POKyUPst7E4W1MHaHuNRn53JcvnLzBScwYLJdh+Y3Q65iiDWq/Km ++S8jgfOv5EcMVZWU3u32pSTuMudreeFCb6QHspUd9amOYEJwQr3iMBhodFIymKmB +2tjGb14dznm28+xP7X0iV9IU0PvyUNSAO4ntd/1FbOZSawpScaxZyNUl9UAD+1Gw +EacAednYTwBDlmhEKUHc0syRyGGVQU0OZlq1FWc+im8p3xyKb+6el5yeaXHTNFJ1 +6ernUXcjmEbKR6LT05cDQUvjMxFyLa+/Kz6zUQIDAQABAoIBAC4sYGuLGf49Ygix +9FXdHFEj4rSyccoWRIhYoq/nHOAC4NkMzvKtQBj95+ABxVK1XstIdMrYwN6zrva8 +8Re9/mzCGwIs5uJj9ll30Y7A34Y+MUP4E7baS4JzKlG8ZZIDm4K2MFHBtXpOl8A5 +pAE+jVIUA9Kt6LohVuNq21SVzdxSfNYC/+SLqSftkWa/ZsqdkiHM5Hl+fVedh516 +IaLNW5hSthGh5n8dHY5h/AKPjfoq77aYp5/CUtJTC9mYdZu1j/W/pBVTRfOnwLQd +SQ1Xmr7f6q9Vmz+HnajIbFg9hQ54blvtUJ7DnugWxfUcoxf7ue79fnYjOIUOkRWw +8Iid/mECgYEA+5s0p0j+gkNZN5QtVNStoT04+1DqA1O381gczeaiz6Njt/MT0y5W +OpCsILQ70CpEjWAV+f6PDJiesDMxdGV+v2TCqK8ml8GahEczBLnHoAkPWCVf2XOX +oNj/CkZ2kmWufHFR+kcQbeDt1vFFcYUa61hKDFyNjinMW79Qy+PQID0CgYEA2gWd +7thE05sqU7/1MntmVRONKoAgnJmHcfSpWwLyh6E4YX3iKDSgI/9RnAMF39KUUY/O +XFWyIwAM9soeXknVsV/SmCaPeaEDiLHqz98aUEfvdLYMnuR883GgoXc4JrLsLw4z +oSi9lbAZFn0ekJ5L+rSFrY4rz9QZgYZxsLjUXKUCgYAbkaUSU2g3w8Np2J2i9u7T +hQ7SUsphdPHqAxSc5xGd6MxLYqIgeKpQHnwN1VHcfFUonIer7d2kxrBUpDdeBqT9 +ub+ulgqHhFo29ko70VNzUKrSwL2g6Q6LPFutt4zUe7nDvvL5loHRWF0XOTafurL5 +aKIsepO0KRZQU0U6IgszDQKBgFs6ZHaP2mTtFY4L0a75Ab3xu20gRgUhHRLq/H6P +wipMpMnuodaPBr9pU53Dig65D8T9Nq1eUnbgy4vs0T5FCPz6iqWN5RVQ8aieQhIP +WfRj1Wfx0WAfXcWEM2G9ACr5TWj3OVVjNclP8X9+hW6gPky+gv03c0+4gZ+4QRRg +ksPdAoGBAKXVv8L5Qt8rmi+QpF/6DVSeMB6lEevf319UEtyZPiYSBn1JJ9H+3Ddc +TMriXgZrw+PoXNJTAguDeGgzmtye1vraP0cjt7aG6sQ4YtMTnnuja880vK8z4i3Q +HJAi5fI8NvlW92dQBAccgrzFIKpRx+6CHwtCkNrxL2vJmGGzp8BR +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/certs/example.crt b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/certs/example.crt new file mode 100644 index 000000000..931874885 --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/certs/example.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEjCCAfqgAwIBAgIJAIAdu56fLE7OMA0GCSqGSIb3DQEBCwUAMBYxFDASBgNV +BAMMC2V4YW1wbGUub3JnMCAXDTIwMDYxMDEwMzM0NFoYDzIxMjAwNTE3MTAzMzQ0 +WjAWMRQwEgYDVQQDDAtleGFtcGxlLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBALv+LV1aWIlcK9rI7IuRS8SCusqBnoyJec/ErKiA2gbfgZ/YS73L +Zud84yp45AIqauzcI5q+hrkmsRZ7CKqDzrG+jHavW7jF+0laetJwRt26AcQcOtQD +2ik2O+Dl1WHAFn4vUAQxb+Xz6WfSaQN0QfM74z06XUDDsr7g7+NYtMzhf98SJSoK +ne/dVKJ3Bc6e6tvhnCRwPtix4ektEodK6WeNHYxwJ6wSZ8cRLzdxgjdD/4OGfFuj +dn8zbOi3SQt5ZqVbcDHUTzp5t0G8EoxnzotHhhzjSAmsypySqZXaxl3oX8aYUkFn +fCdg+WBXo5pOiNfoWh/D5bnIXWGp52yoy+kCAwEAAaNhMF8wHQYDVR0lBBYwFAYI +KwYBBQUHAwIGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFIH+0G3eCswQHbN06kvI80M3 +tNH9MB0GA1UdDgQWBBSxLHQE7gOEyfeSNc5uIO/G/rgjpzANBgkqhkiG9w0BAQsF +AAOCAQEAlGm5RwQ79xmLh3rj+5UViCSgsIuMcuhgIT4zogpo9S4uwXMqinrJhzRk +Oc2tb3y06XTAq1lMH2+58tqndAu8ni/UBz3OSghk2CTnZ1vxxXOd3CtQu4ypMq+k +qW0Umdrkk5TeAODNbrCy4c6vpICkQOljnRFWnDYu3aQ3JvaWZ/nObN7C72Lgpjfb +RfLXGmLsBCEIr028f9hpoeRCXoetUY2CiC2boAHR+cO6Jpvex4Jv5yYDpNKac52n +LLC8Cq5ozhOZNOSV6X9FpEca3rdhVUb0VgoNDCPZDdpO1PDJYCN9fUC8KUs+dsOh +SYGpliBaNsztoiJs1q5SkMQrMwmMmg== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/certs/example.key b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/certs/example.key new file mode 100644 index 000000000..b21c2f08d --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/certs/example.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAu/4tXVpYiVwr2sjsi5FLxIK6yoGejIl5z8SsqIDaBt+Bn9hL +vctm53zjKnjkAipq7Nwjmr6GuSaxFnsIqoPOsb6Mdq9buMX7SVp60nBG3boBxBw6 +1APaKTY74OXVYcAWfi9QBDFv5fPpZ9JpA3RB8zvjPTpdQMOyvuDv41i0zOF/3xIl +Kgqd791UoncFzp7q2+GcJHA+2LHh6S0Sh0rpZ40djHAnrBJnxxEvN3GCN0P/g4Z8 +W6N2fzNs6LdJC3lmpVtwMdRPOnm3QbwSjGfOi0eGHONICazKnJKpldrGXehfxphS +QWd8J2D5YFejmk6I1+haH8PluchdYannbKjL6QIDAQABAoIBAEUBL7WsjAMfihls +1ycD1kPzmIzstz3u2H+jOZ1AbsdHE1WRF3w7RTKDbP8SEN+aolT/GTKb7OfZg/c0 +giHU7/Hed8C47XoNcgei5qKIA/svY6aQlidsoo+uEJykwIZ488itpTlkzCYkOfCa +E2HpMqwNt4OqAMDdFKdr+aIB1Zu+KPBxW23WD9wEWAbe5LA4YnRF9kT4YZ6y9mce +dGaIf39VtBlrGMmvoU0LE9B79nyuebGi0svW6QDarBqaDrnM/N3fXgL1kk/gVfan +/xs6EA4qPxA5G4h+enYrIlZL0CbSj60nYElo+Z5nRdBaRdCF/bpXOLyK/kXWLUM0 +f2HTK+ECgYEA5cVcpJtczxEaxoaEUbppsW1LCrTjJDGTKJ63G7/lwqCxJeCHN185 +nnckHOW2287e19bu9aUmKJgRq5s1rXnT+MnCl/hQnfaMrKOKtzE+t7/zsc9+LuAr +pwJrtZ9Dcnwrk8NOE0fPjW5XpDCSoEOo7JWZmGTVlpabOgNkjocfp+sCgYEA0XPt +ZPt3F0wyzgLYRhgnvp5CV8SzQmulsW+ytnL5eiAcNSXqni3wQHN3PGxLInEyQwBQ +/M8TQpUbqGMmahCK4ZxMAwXMrpF0mVB8jfoYMou1FSYPlUV+CvLjWcTkZB1Nirez +VFXdtfHP0mx4PbYK2qjB03u4pPHAN8kuayIf2nsCgYAbv/FHZAgabfto3Jggcr4P +Ep8MhPolxeL69egxbsSl89hRNcO+2T5ROBxhbRDfjSV2tduYSUDJiEwiCJW8BMmn +814QEopR+ZPVyc6X/1eOw5z/7YpUyPgcrHsrrTdtHTf6GY1VYMfdUeU9zCv5NRKy +uAKb2Bm/nSLUJ9K+L+2PzwKBgQDRQgT3UtTUjehkMitpPFDY/LxDe92simfsMjBW +X+Anx1TnNI6GolbZzYJe98LJElao4fQH38raRqZvQT/rz8MxTDoU+wJXljLryaHn +Jupt9W5hRrli5R7cSXYjBbc43p3N7WJY68CqOoDrNjubS/jkJJ4hcAY1pOHp2jFq +D5nLaQKBgAysU6O5kJ8yKxhbZflb42MqKCFBGrbRnbYx14PAEZOaRhzxehpppQmx +RLbn/z1Uh5Ms28ipxA+vnhyM3FcU5lKboaFyWJeuNslw0FxEcIai6hL6UkDznS4G +aqyzUjpG5Chg0x18xWYCbiGJwjZ9BWhtH+jojm856QHGzeQJWVoo +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/client_ca/example_ca.crt b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/client_ca/example_ca.crt new file mode 100644 index 000000000..b0fa2fa28 --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/client_ca/example_ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDATCCAemgAwIBAgIJAMsPuHg4mqnaMA0GCSqGSIb3DQEBCwUAMBYxFDASBgNV +BAMMC2V4YW1wbGUub3JnMCAXDTIwMDYxMDEwMzMwNloYDzIxMjAwNTE3MTAzMzA2 +WjAWMRQwEgYDVQQDDAtleGFtcGxlLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBAL72D2gOnJN9Zvo9KjruwM1EsFe3xZRJ0NvZ5bHd6+5jhlgCAhQ+ +AGb7ufEiYOi2JWHl2Bkq0iVrp+zv0RLdq0oVjX+OG5H2yWbnC7ifbNjir93LX0un +tIqv5CIbExDSBRkufxfV37yjXdrcMqYSbD2Kw3PfAbWs1Dego8fRz8QAp5+LCvW2 +BZZyYi6JzhCAUW1+8OQPyzOhB50eSJiS5xgVA7wkwmYeVUpHqU8sv4VzjM3bmUc7 +1mPLlnRVIqScrqYgQ9Ou21vZebOJ8+ckVL8O3XHhMZlssBbWiBFZZnaNWbzcEI90 +oiW4YAQ5t7gaXuCVvaiNvq2VZknarR6AxcsCAwEAAaNQME4wHQYDVR0OBBYEFIH+ +0G3eCswQHbN06kvI80M3tNH9MB8GA1UdIwQYMBaAFIH+0G3eCswQHbN06kvI80M3 +tNH9MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJtHx8ibLp+CYDR1 +ZVeQ3qg4OeRL0q21+2EgBJq6zOUt/9SxThA80aXJ8CYH9dnCW+fOnpEk1xFWxtXc +FwSLsnqAdwOJaaQWoMzhyqjZV5x5G9+MW5FzGGOdes2md2Z+tAwMoV9TVtxZkbKy +mC2tDJdvgLgt9/YcbUcZPDbyZojdZ+UbATm+Lro9dhTXt91vsAgz5QA9e08rQVkF +pc9+ZQ5zxBsoblQ+ozPOWOdV4zJVx+wQsAnOG2qU0yVQAscGsTo4wnzFrAU54fO7 +Lh4cOrY0P1/o65yiSzwK7f0jwBeT/jEfMOrJ7pPo7doUov0iVj0SZyTM3HFa2Mzj +2zGTk0o= +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/client_ca/example_ca.key b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/client_ca/example_ca.key new file mode 100644 index 000000000..4f7ad13b1 --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/client_ca/example_ca.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAvvYPaA6ck31m+j0qOu7AzUSwV7fFlEnQ29nlsd3r7mOGWAIC +FD4AZvu58SJg6LYlYeXYGSrSJWun7O/REt2rShWNf44bkfbJZucLuJ9s2OKv3ctf +S6e0iq/kIhsTENIFGS5/F9XfvKNd2twyphJsPYrDc98BtazUN6Cjx9HPxACnn4sK +9bYFlnJiLonOEIBRbX7w5A/LM6EHnR5ImJLnGBUDvCTCZh5VSkepTyy/hXOMzduZ +RzvWY8uWdFUipJyupiBD067bW9l5s4nz5yRUvw7dceExmWywFtaIEVlmdo1ZvNwQ +j3SiJbhgBDm3uBpe4JW9qI2+rZVmSdqtHoDFywIDAQABAoIBAD60bbqtkZycwQPK +seNIIudEduNW5PocgwiuNE6DoMVWyPZ9MlGTSm6GmjgkIc5IgV30K1GYTgkboLic +xvp675QUH7KS51q2vsubcq3dK9DMHxOlhFVDbHVd7HuGiGwtip8KNZGOGTnIKzmC +tN7zjbdnqWaTA+y0I7tgdGdY7fBd9Rzgaq+OlPq2u33HvWHvlG/7PfpZuXB5YLgd +m04l7LJ7ikhIjycg7j27v/4c6xCiH5jMJKsZ+nfsQ0kEEo9DkhcKInK+wHsMzKsH +Cy3AdlE0IRsbxRAoMumVs2g5u90m3zBPkRrNdZ2Ni7BesnhxbkIqvb4SfpxKyuhK +fADfZgECgYEA7SUIS2gII0TGjXh0h16d+eoLVOz0eVpgF3XXxmgtAPu10dqxVEC2 +j5FSBCgZhqZ3axVotP71c2mT+hF+Mqy4TLMfA/B9jKLXjZlPbg4EcAgI7tALskwz +Bk5BkX0k825bU9P0j+AlpLx6/ztHr2N9/cKZfqQVrO9t+FRusvAHkHsCgYEAziT8 +F30Ch2s6IJngCj5jH164iN2CoFXjqPNVgRj45gLE3zrf1R7u2JTeEhjWNLZ6IWZ3 +G/bT7eYm6x8u7LFlORdnWKsHlftGu0igRyvIGcxoHgjXlsLidBaEP+HlOLUtTumu +MfQJUozLcrOBIV6m9VhPnSDTeCg/tOqy68V2pvECgYAUYgd5e8KfTW0Hgd/6Nq67 +aVt5/DfzKkpyGcXnHtMnb3ssQ3DUfg9y/ZmgE9ZF1Y8UHC34yKVOOzfl2ZUQQ/o/ +VXIIA6a27NQ8Ln4+RmQpQPeLl0Q6GgSUuSs3lxsS9VxSMzilGS4DH9QulejOcW3F +3vEUioP2bkn0e0VcifcMewKBgAG1Pr13FLFIiye//qI3GB0nbMH9i9qGO6entHqo +WU+WkEkFNNuQMQxsV1axC/1N0b87GRuLNQBQmtvx2zKs2Zjaf8m1SQ/OECz3EhTk +4PiNwAMXsamXHcc2dIwO9BY/MgvoVcAmNHmRnxHpONWs8hcwTyCPKBFjy/tUwny/ +mxcRAoGAcNRxLZlyRqmQ6zGf41GK4ZIR9gix0L6Km49S1maGFmcbctOR2GcQN8Eo +f38rkrFBfBfuFSGzghJiXDvKORg9r3V/bzSKcXkprJra6hzn5vn+t7wurjzaJUlK +zUW5dl3SU2bC4MK+X7bwqf9jm9b7FXSt4p1xly8Uh/Mufwi7zOM= +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/server_cert_conf.data b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/server_cert_conf.data new file mode 100644 index 000000000..49f228531 --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/server_cert_conf.data @@ -0,0 +1,12 @@ +{ + "Version": "init version", + "Config": { + "Default": "example.org", + "CertConf": { + "example.org": { + "ServerCertFile": "tls_conf/certs/example.crt", + "ServerKeyFile" : "tls_conf/certs/example.key" + } + } + } +} diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/session_ticket_key.data b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/session_ticket_key.data new file mode 100644 index 000000000..b3d2356b0 --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/session_ticket_key.data @@ -0,0 +1,4 @@ +{ + "Version": "init version", + "SessionTicketKey": "08a0d852ef494143af613ef32d3c39314758885f7108e9ab021d55f422a454f7c9cd5a53978f48fa1063eadcdc06878f" +} diff --git a/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/tls_rule_conf.data b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/tls_rule_conf.data new file mode 100644 index 000000000..66e7b5dbb --- /dev/null +++ b/tests/integration/implementation/scenario-SC08-ai-model-fallback-recompute/testdata/tls_conf/tls_rule_conf.data @@ -0,0 +1,20 @@ +{ + "Version": "12", + "DefaultNextProtos": ["http/1.1"], + "Config": { + "example_product": { + "VipConf": [ + "10.199.4.14" + ], + "SniConf": ["example.org"], + "CertName": "example.org", + "NextProtos": [ + "h2;rate=100;isw=65535;mcs=200;level=0", + "http/1.1" + ], + "Grade": "C", + "ClientAuth": false, + "ClientCAName": "example_ca" + } + } +} diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-01-OpenAI\351\243\216\346\240\274\350\275\254\345\217\221.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-01-OpenAI\351\243\216\346\240\274\350\275\254\345\217\221.md" new file mode 100644 index 000000000..cf4d45fd8 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-01-OpenAI\351\243\216\346\240\274\350\275\254\345\217\221.md" @@ -0,0 +1,56 @@ +# TC-01 OpenAI 风格转发 + +## 用例编号与名称 + +TC-01 OpenAI 风格转发 + +## 所属场景 + +SC06 Claude 协议支持 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证 OpenAI 风格请求被正确路由到仅支持 `openai` 协议的集群,并使用 `Authorization: Bearer` 注入集群 key。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. mock 后端 `cluster_openai_only` 已启动,返回 200。 +3. 临时 BFE 配置已生成并加载,`cluster_openai_only` 配置 `ModelProtocols = ["openai"]`,Key 为 `openai-key`。 +4. `ai_route.data` 中 `apikey_ak_user_a` 命中 `user_a-openai`,target 为 `cluster_openai_only`。 + +## 配置构造 + +- `cluster_openai_only.AIConf`: + - `ModelProtocols`: `["openai"]` + - `Keys`: `[{"Name":"openai-key","Key":"sk-openai-key","Weight":100}]` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `openai.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"gpt-4"}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_openai_only` mock backend 命中 1 次。 +- upstream 收到的 `Authorization` 头为 `Bearer sk-openai-key`。 +- upstream 未收到 `x-api-key` 头。 + +## 清理 + +停止 `bfe` 进程、mock 后端,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-02-Anthropic\351\243\216\346\240\274\350\275\254\345\217\221.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-02-Anthropic\351\243\216\346\240\274\350\275\254\345\217\221.md" new file mode 100644 index 000000000..4c2451e51 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-02-Anthropic\351\243\216\346\240\274\350\275\254\345\217\221.md" @@ -0,0 +1,57 @@ +# TC-02 Anthropic 风格转发 + +## 用例编号与名称 + +TC-02 Anthropic 风格转发 + +## 所属场景 + +SC06 Claude 协议支持 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证 Anthropic 风格请求被正确路由到仅支持 `anthropic` 协议的集群,并使用 `x-api-key` 注入集群 key,同时自动补 `anthropic-version`。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. mock 后端 `cluster_anthropic_only` 已启动,返回 200。 +3. 临时 BFE 配置已生成并加载,`cluster_anthropic_only` 配置 `ModelProtocols = ["anthropic"]`,Key 为 `anthropic-key`。 +4. `ai_route.data` 中 `apikey_ak_user_a` 命中 `user_a-anthropic`,target 为 `cluster_anthropic_only`。 + +## 配置构造 + +- `cluster_anthropic_only.AIConf`: + - `ModelProtocols`: `["anthropic"]` + - `Keys`: `[{"Name":"anthropic-key","Key":"sk-anthropic-key","Weight":100}]` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `anthropic.example.org` | +| Path | `/v1/messages` | +| x-api-key | `ak_user_a` | +| Body | `{"model":"claude-3-5-sonnet","messages":[{"role":"user","content":"hi"}]}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_anthropic_only` mock backend 命中 1 次。 +- upstream 收到的 `x-api-key` 头为 `sk-anthropic-key`。 +- upstream 收到的 `anthropic-version` 头为 `2023-06-01`。 +- upstream 未收到 `Authorization` 头。 + +## 清理 + +停止 `bfe` 进程、mock 后端,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-03-Anthropic\350\257\267\346\261\202\345\221\275\344\270\255OpenAI-only\351\233\206\347\276\244.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-03-Anthropic\350\257\267\346\261\202\345\221\275\344\270\255OpenAI-only\351\233\206\347\276\244.md" new file mode 100644 index 000000000..1b107f83f --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-03-Anthropic\350\257\267\346\261\202\345\221\275\344\270\255OpenAI-only\351\233\206\347\276\244.md" @@ -0,0 +1,55 @@ +# TC-03 Anthropic 请求命中 OpenAI-only 集群 + +## 用例编号与名称 + +TC-03 Anthropic 请求命中 OpenAI-only 集群 + +## 所属场景 + +SC06 Claude 协议支持 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证请求协议与集群 `ModelProtocols` 不匹配时,BFE 直接拒绝且不访问 upstream。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. mock 后端 `cluster_openai_only` 已启动。 +3. 临时 BFE 配置已生成并加载,`cluster_openai_only` 配置 `ModelProtocols = ["openai"]`。 +4. `ai_route.data` 中 `apikey_ak_user_a` 命中 `user_a-openai`,target 为 `cluster_openai_only`。 + +## 配置构造 + +- `cluster_openai_only.AIConf`: + - `ModelProtocols`: `["openai"]` + - `Keys`: `[{"Name":"openai-key","Key":"sk-openai-key","Weight":100}]` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `openai.example.org` | +| Path | `/v1/messages` | +| x-api-key | `ak_user_a` | +| Body | `{"model":"claude-3-5-sonnet","messages":[{"role":"user","content":"hi"}]}` | + +## 预期结果 + +- 响应状态码:400。 +- 响应体包含 `PROVIDER_PROTOCOL_MISMATCH`。 +- `cluster_openai_only` mock backend 命中 0 次。 + +## 清理 + +停止 `bfe` 进程、mock 后端,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-04-OpenAI\350\257\267\346\261\202\345\221\275\344\270\255Anthropic-only\351\233\206\347\276\244.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-04-OpenAI\350\257\267\346\261\202\345\221\275\344\270\255Anthropic-only\351\233\206\347\276\244.md" new file mode 100644 index 000000000..459116618 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-04-OpenAI\350\257\267\346\261\202\345\221\275\344\270\255Anthropic-only\351\233\206\347\276\244.md" @@ -0,0 +1,55 @@ +# TC-04 OpenAI 请求命中 Anthropic-only 集群 + +## 用例编号与名称 + +TC-04 OpenAI 请求命中 Anthropic-only 集群 + +## 所属场景 + +SC06 Claude 协议支持 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证 OpenAI 风格请求命中仅支持 `anthropic` 的集群时同样被拒绝。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. mock 后端 `cluster_anthropic_only` 已启动。 +3. 临时 BFE 配置已生成并加载,`cluster_anthropic_only` 配置 `ModelProtocols = ["anthropic"]`。 +4. `ai_route.data` 中 `apikey_ak_user_a` 命中 `user_a-anthropic`,target 为 `cluster_anthropic_only`。 + +## 配置构造 + +- `cluster_anthropic_only.AIConf`: + - `ModelProtocols`: `["anthropic"]` + - `Keys`: `[{"Name":"anthropic-key","Key":"sk-anthropic-key","Weight":100}]` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `anthropic.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"gpt-4"}` | + +## 预期结果 + +- 响应状态码:400。 +- 响应体包含 `PROVIDER_PROTOCOL_MISMATCH`。 +- `cluster_anthropic_only` mock backend 命中 0 次。 + +## 清理 + +停止 `bfe` 进程、mock 后端,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-05-\345\244\232\345\215\217\350\256\256\351\233\206\347\276\244\345\220\214\346\227\266\346\224\257\346\214\201\344\270\244\347\247\215\350\257\267\346\261\202.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-05-\345\244\232\345\215\217\350\256\256\351\233\206\347\276\244\345\220\214\346\227\266\346\224\257\346\214\201\344\270\244\347\247\215\350\257\267\346\261\202.md" new file mode 100644 index 000000000..4014b71a9 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-05-\345\244\232\345\215\217\350\256\256\351\233\206\347\276\244\345\220\214\346\227\266\346\224\257\346\214\201\344\270\244\347\247\215\350\257\267\346\261\202.md" @@ -0,0 +1,63 @@ +# TC-05 多协议集群同时支持两种请求 + +## 用例编号与名称 + +TC-05 多协议集群同时支持两种请求 + +## 所属场景 + +SC06 Claude 协议支持 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证 `ModelProtocols` 同时包含 `openai` 与 `anthropic` 的集群能够按请求风格选择正确的 key 与头部。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. mock 后端 `cluster_both_protocols` 已启动,返回 200。 +3. 临时 BFE 配置已生成并加载,`cluster_both_protocols` 配置 `ModelProtocols = ["openai", "anthropic"]`,配置两把 key。 +4. `ai_route.data` 中 `apikey_ak_user_a` 命中 `user_a-both`,target 为 `cluster_both_protocols`。 + +## 配置构造 + +- `cluster_both_protocols.AIConf`: + - `ModelProtocols`: `["openai", "anthropic"]` + - `Keys`: + - `{"Name":"both-openai-key","Key":"sk-both-openai-key","Weight":50}` + - `{"Name":"both-anthropic-key","Key":"sk-both-anthropic-key","Weight":50}` + +## BFE 请求 + +向 `both.example.org` 连续发送 2 次 POST 请求: + +1. OpenAI 风格: + - Path: `/v1/chat/completions` + - Authorization: `Bearer ak_user_a` + - Body: `{"model":"gpt-4"}` + +2. Anthropic 风格: + - Path: `/v1/messages` + - x-api-key: `ak_user_a` + - Body: `{"model":"claude-3-5-sonnet","messages":[{"role":"user","content":"hi"}]}` + +## 预期结果 + +- 两个请求均返回 200。 +- `cluster_both_protocols` mock backend 命中 2 次。 +- OpenAI 请求 upstream 收到 `Authorization: Bearer sk-both-openai-key`(或对应 Anthropic key 以 Bearer 形式注入,取决于加权随机选中的 key)。 +- Anthropic 请求 upstream 收到 `x-api-key: sk-both-anthropic-key` 与 `anthropic-version: 2023-06-01`。 + +> 说明:由于加权随机选 key,断言策略为“存在符合协议风格的 header 即可”,不强制固定某一把 key。 + +## 清理 + +停止 `bfe` 进程、mock 后端,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-06-\345\217\214\345\244\264\345\255\230\345\234\250\346\227\266Authorization\344\274\230\345\205\210.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-06-\345\217\214\345\244\264\345\255\230\345\234\250\346\227\266Authorization\344\274\230\345\205\210.md" new file mode 100644 index 000000000..bd35ac7fe --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-06-\345\217\214\345\244\264\345\255\230\345\234\250\346\227\266Authorization\344\274\230\345\205\210.md" @@ -0,0 +1,54 @@ +# TC-06 双头存在时 Authorization 优先 + +## 用例编号与名称 + +TC-06 双头存在时 Authorization 优先 + +## 所属场景 + +SC06 Claude 协议支持 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证请求同时携带 `Authorization` 与 `x-api-key` 时,BFE 按 OpenAI 风格处理(`DetectAuthStyle` 优先识别 Authorization)。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. mock 后端 `cluster_both_protocols` 已启动,返回 200。 +3. 临时 BFE 配置已生成并加载,`cluster_both_protocols` 配置 `ModelProtocols = ["openai", "anthropic"]`。 + +## 配置构造 + +- `cluster_both_protocols.AIConf`: + - `ModelProtocols`: `["openai", "anthropic"]` + - `Keys`: 多把 key(详见 TC-05) + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `both.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| x-api-key | `ak_user_a` | +| Body | `{"model":"gpt-4"}` | + +## 预期结果 + +- 响应状态码:200。 +- upstream 收到的 `Authorization` 头携带集群 key(`Bearer sk-both-openai-key` 或 `Bearer sk-both-anthropic-key`)。 + +## 清理 + +停止 `bfe` 进程、mock 后端,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-07-\346\230\276\345\274\217anthropic-version\350\242\253\344\277\235\347\225\231.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-07-\346\230\276\345\274\217anthropic-version\350\242\253\344\277\235\347\225\231.md" new file mode 100644 index 000000000..c49529adf --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-07-\346\230\276\345\274\217anthropic-version\350\242\253\344\277\235\347\225\231.md" @@ -0,0 +1,55 @@ +# TC-07 显式 anthropic-version 被保留 + +## 用例编号与名称 + +TC-07 显式 anthropic-version 被保留 + +## 所属场景 + +SC06 Claude 协议支持 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证客户端显式传递的 `anthropic-version` 不会被 BFE 的默认值覆盖。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. mock 后端 `cluster_anthropic_only` 已启动,返回 200。 +3. 临时 BFE 配置已生成并加载,`cluster_anthropic_only` 配置 `ModelProtocols = ["anthropic"]`。 + +## 配置构造 + +- `cluster_anthropic_only.AIConf`: + - `ModelProtocols`: `["anthropic"]` + - `Keys`: `[{"Name":"anthropic-key","Key":"sk-anthropic-key","Weight":100}]` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `anthropic.example.org` | +| Path | `/v1/messages` | +| x-api-key | `ak_user_a` | +| anthropic-version | `2023-06-02` | +| Body | `{"model":"claude-3-5-sonnet","messages":[{"role":"user","content":"hi"}]}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_anthropic_only` mock backend 命中 1 次。 +- upstream 收到的 `anthropic-version` 头仍为 `2023-06-02`。 + +## 清理 + +停止 `bfe` 进程、mock 后端,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-08-Claude usage\345\255\227\346\256\265\350\247\243\346\236\220.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-08-Claude usage\345\255\227\346\256\265\350\247\243\346\236\220.md" new file mode 100644 index 000000000..3704a0591 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC06-Claude\345\215\217\350\256\256\346\224\257\346\214\201/TC-08-Claude usage\345\255\227\346\256\265\350\247\243\346\236\220.md" @@ -0,0 +1,65 @@ +# TC-08 Claude usage 字段解析 + +## 用例编号与名称 + +TC-08 Claude usage 字段解析 + +## 所属场景 + +SC06 Claude 协议支持 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证 Claude 响应中的 `input_tokens`、`output_tokens` 及 cache 相关字段可被 BFE 正常解析,不影响响应返回。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. mock 后端 `cluster_anthropic_only` 已启动,返回如下 body: + ```json + { + "content": [{"text":"ok"}], + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "cache_read_input_tokens": 30, + "cache_creation_input_tokens": 20 + } + } + ``` +3. 临时 BFE 配置已生成并加载,`cluster_anthropic_only` 配置 `ModelProtocols = ["anthropic"]`。 + +## 配置构造 + +- `cluster_anthropic_only.AIConf`: + - `ModelProtocols`: `["anthropic"]` + - `Keys`: `[{"Name":"anthropic-key","Key":"sk-anthropic-key","Weight":100}]` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `anthropic.example.org` | +| Path | `/v1/messages` | +| x-api-key | `ak_user_a` | +| Body | `{"model":"claude-3-5-sonnet","messages":[{"role":"user","content":"hi"}]}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_anthropic_only` mock backend 命中 1 次。 +- BFE 不报错、不中断响应。 + +## 清理 + +停止 `bfe` 进程、mock 后端,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/TC-01-\350\247\204\345\210\231\351\207\215\345\221\275\345\220\215\345\220\216RPM\350\256\241\346\225\260\345\231\250\344\277\235\346\214\201\350\277\236\347\273\255.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/TC-01-\350\247\204\345\210\231\351\207\215\345\221\275\345\220\215\345\220\216RPM\350\256\241\346\225\260\345\231\250\344\277\235\346\214\201\350\277\236\347\273\255.md" new file mode 100644 index 000000000..943a3c05c --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/TC-01-\350\247\204\345\210\231\351\207\215\345\221\275\345\220\215\345\220\216RPM\350\256\241\346\225\260\345\231\250\344\277\235\346\214\201\350\277\236\347\273\255.md" @@ -0,0 +1,67 @@ +# TC-01 规则重命名后 RPM 计数器保持连续 + +## 用例编号与名称 + +TC-01 规则重命名后 RPM 计数器保持连续 + +## 所属场景 + +SC07 AI 限流 Redis 键 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证 `redis_key` 优先级高于规则名,规则重命名不会导致计数器重置。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动。 +3. mock 后端 `cluster_ratelimit` 已启动,返回 200。 +4. 临时 BFE 配置已生成并加载,包含 `mod_ai_rate_limit` 规则: + - 规则名:`old-rule` + - 模型:`gpt-4` + - `redis_key`: `RL_RPM_rlp-0001_0` + - `MaxRequests`: 1 + - `WindowMinutes`: 1 + - `Burst`: 1 +5. `ai_route.data` 中 `apikey_ak_ratelimit` 命中 `ratelimit-default`,target 为 `cluster_ratelimit`。 +6. `ak_ratelimit` 已绑定限流策略 `rlp-0001`。 + +## 配置构造 + +- `cluster_ratelimit.AIConf`: + - `Keys`: `[{"Name":"ratelimit-key","Key":"sk-ratelimit-key","Weight":100}]` +- 限流规则 RPM: + - `Name`: `old-rule` + - `Models`: `["gpt-4"]` + - `RedisKey`: `RL_RPM_rlp-0001_0` + - `MaxRequests`: 1 + - `WindowMinutes`: 1 + - `Burst`: 1 + +## BFE 请求 + +发送 3 次 POST 请求: + +| 步骤 | Host | Path | Authorization | Body | +|------|------|------|---------------|------| +| 1 | `ratelimit.example.org` | `/v1/chat/completions` | `Bearer ak_ratelimit` | `{"model":"gpt-4"}` | +| 2 | `ratelimit.example.org` | `/v1/chat/completions` | `Bearer ak_ratelimit` | `{"model":"gpt-4"}` | +| 3(重启 BFE 后,规则名改为 `new-rule`) | `ratelimit.example.org` | `/v1/chat/completions` | `Bearer ak_ratelimit` | `{"model":"gpt-4"}` | + +## 预期结果 + +- 三次请求分别返回 200、429、429。 +- `cluster_ratelimit` mock backend 被命中 1 次。 + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/TC-02-\346\250\241\345\236\213\345\217\230\346\233\264\345\220\216RPM\350\256\241\346\225\260\345\231\250\344\277\235\346\214\201\350\277\236\347\273\255.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/TC-02-\346\250\241\345\236\213\345\217\230\346\233\264\345\220\216RPM\350\256\241\346\225\260\345\231\250\344\277\235\346\214\201\350\277\236\347\273\255.md" new file mode 100644 index 000000000..6a8695d02 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/TC-02-\346\250\241\345\236\213\345\217\230\346\233\264\345\220\216RPM\350\256\241\346\225\260\345\231\250\344\277\235\346\214\201\350\277\236\347\273\255.md" @@ -0,0 +1,63 @@ +# TC-02 模型变更后 RPM 计数器保持连续 + +## 用例编号与名称 + +TC-02 模型变更后 RPM 计数器保持连续 + +## 所属场景 + +SC07 AI 限流 Redis 键 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证 `redis_key` 不变时,模型变更不会导致计数器重置。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动。 +3. mock 后端 `cluster_ratelimit` 已启动,返回 200。 +4. 临时 BFE 配置已生成并加载,包含 `mod_ai_rate_limit` 规则: + - 规则名:`rule-1` + - 模型:`gpt-4` + - `redis_key`: `RL_RPM_rlp-0001_0` + - `MaxRequests`: 1 + - `WindowMinutes`: 1 + - `Burst`: 1 + +## 配置构造 + +- `cluster_ratelimit.AIConf`: + - `Keys`: `[{"Name":"ratelimit-key","Key":"sk-ratelimit-key","Weight":100}]` +- 限流规则 RPM(初始): + - `Name`: `rule-1` + - `Models`: `["gpt-4"]` + - `RedisKey`: `RL_RPM_rlp-0001_0` + - `MaxRequests`: 1 + - `WindowMinutes`: 1 + - `Burst`: 1 + +## BFE 请求 + +| 步骤 | Body | 说明 | +|------|------|------| +| 1 | `{"model":"gpt-4"}` | 初始模型 | +| 2 | `{"model":"gpt-4"}` | 同一模型,应被限流 | +| 3(重启 BFE 后,规则模型改为 `gpt-3.5`) | `{"model":"gpt-3.5"}` | 模型变更,但 `redis_key` 不变 | + +## 预期结果 + +- 三次请求分别返回 200、429、429。 +- `cluster_ratelimit` mock backend 被命中 1 次。 + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/TC-03-\346\234\252\351\205\215\347\275\256redis_key\346\227\266\346\214\211\350\247\204\345\210\231\345\220\215\345\233\236\351\200\200.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/TC-03-\346\234\252\351\205\215\347\275\256redis_key\346\227\266\346\214\211\350\247\204\345\210\231\345\220\215\345\233\236\351\200\200.md" new file mode 100644 index 000000000..9d3b66671 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/TC-03-\346\234\252\351\205\215\347\275\256redis_key\346\227\266\346\214\211\350\247\204\345\210\231\345\220\215\345\233\236\351\200\200.md" @@ -0,0 +1,64 @@ +# TC-03 未配置 redis_key 时按规则名回退 + +## 用例编号与名称 + +TC-03 未配置 redis_key 时按规则名回退 + +## 所属场景 + +SC07 AI 限流 Redis 键 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证未配置 `redis_key` 时,BFE 回退到基于规则名生成 Redis key,保持向后兼容。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动。 +3. mock 后端 `cluster_ratelimit` 已启动,返回 200。 +4. 临时 BFE 配置已生成并加载,包含 `mod_ai_rate_limit` 规则: + - 规则名:`name-based-rule` + - 模型:`gpt-4` + - `redis_key`: 空 + - `MaxRequests`: 1 + - `WindowMinutes`: 1 + - `Burst`: 1 + +## 配置构造 + +- `cluster_ratelimit.AIConf`: + - `Keys`: `[{"Name":"ratelimit-key","Key":"sk-ratelimit-key","Weight":100}]` +- 限流规则 RPM: + - `Name`: `name-based-rule` + - `Models`: `["gpt-4"]` + - `RedisKey`: `""` + - `MaxRequests`: 1 + - `WindowMinutes`: 1 + - `Burst`: 1 + +## BFE 请求 + +发送 2 次 POST 请求: + +| 步骤 | Host | Path | Authorization | Body | +|------|------|------|---------------|------| +| 1 | `ratelimit.example.org` | `/v1/chat/completions` | `Bearer ak_ratelimit` | `{"model":"gpt-4"}` | +| 2 | `ratelimit.example.org` | `/v1/chat/completions` | `Bearer ak_ratelimit` | `{"model":"gpt-4"}` | + +## 预期结果 + +- 两次请求分别返回 200、429。 +- `cluster_ratelimit` mock backend 被命中 1 次。 + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/\345\234\272\346\231\257\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/\345\234\272\346\231\257\350\257\264\346\230\216.md" new file mode 100644 index 000000000..61a4e6d6c --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC07-AI\351\231\220\346\265\201Redis\351\224\256/\345\234\272\346\231\257\350\257\264\346\230\216.md" @@ -0,0 +1,183 @@ +# SC07 AI 限流 Redis 键 + +## 1. 场景背景与目的 + +`mod_ai_rate_limit` 支持按规则维度对 AI 请求进行 RPM/TPM 限流。限流计数器持久化在 Redis 中,Redis 键的生成规则直接影响计数器在规则重命名、模型变更后的连续性。本场景验证: + +- 当限流规则显式配置 `redis_key` 时,BFE 使用该 key 读写 Redis 计数器; +- 规则名变更但 `redis_key` 不变时,RPM 计数器保持连续; +- 规则模型变更但 `redis_key` 不变时,RPM 计数器保持连续; +- 未配置 `redis_key` 时,BFE 回退到基于规则名生成 Redis key,保持向后兼容。 + +## 2. 运行模式 + +- **单组件模式**:仅启动真实 `bfe` 进程,并启动内存 Redis 服务(由测试框架提供)。 +- 不涉及 `ai-gateway-api` 与 `conf-agent`。 + +## 3. 涉及的 BFE 配置文件 + +| 文件 | 说明 | +|------|------| +| `bfe.conf` | 启用 `EnableAiGateway`,加载 `mod_ai_route`、`mod_ai_token_auth`、`mod_ai_rate_limit` | +| `mod_ai_route/mod_ai_route.conf` | 指定 `ai_route.data` 路径 | +| `mod_ai_route/ai_route.data` | 定义 apikey 级路由规则,命中 `cluster_ratelimit` | +| `mod_ai_token_auth/mod_ai_token_auth.conf` | 指定 token 规则路径 | +| `mod_ai_rate_limit/mod_ai_rate_limit.conf` | 指定限流规则路径 | +| `server_data_conf/host_rule.data` | 声明 `ai_product` 产品线对应的 Host | +| `server_data_conf/route_rule.data` | 默认路由 | +| `cluster_conf/gslb.data` | GSLB 权重配置 | +| `cluster_conf/cluster_table.data` | 运行时根据 mock 后端地址动态生成 | +| `cluster_conf/cluster_conf.data` | 运行时根据 mock 后端动态生成,包含 `cluster_ratelimit` 的 `AIConf` | + +> 说明:`ai_rate_limit.data` 由测试代码在运行时根据 `rateLimitPolicy()` 动态生成,便于控制规则名、模型与 `redis_key`。 + +## 4. 测试 Cluster 与限流配置 + +### 4.1 cluster_ratelimit + +```json +{ + "AIConf": { + "Type": 0, + "Keys": [ + {"Name": "ratelimit-key", "Key": "sk-ratelimit-key", "Weight": 100} + ] + } +} +``` + +### 4.2 限流规则 + +```json +{ + "Name": "rlp-0001", + "Enabled": true, + "Rules": { + "RPM": [ + { + "Name": "old-rule", + "WindowMinutes": 1, + "MaxRequests": 1, + "Burst": 1, + "Models": ["gpt-4"], + "RedisKey": "RL_RPM_rlp-0001_0" + } + ] + } +} +``` + +> 说明:测试通过改变 `Name`(规则名)与 `Models`(模型),保持 `RedisKey` 不变,验证计数器持久性。 + +### 4.3 路由表设计 + +#### apikey_ak_ratelimit + +| 规则名 | 条件 | targets | fallbacks | +|--------|------|---------|-----------| +| ratelimit-default | `default_t()` | `cluster_ratelimit` | 无 | + +#### ApikeyRouteTableBindings + +```json +{ + "ak_ratelimit": ["apikey_ak_ratelimit"] +} +``` + +## 5. 测试例列表 + +| 编号 | 名称 | 核心验证点 | +|------|------|------------| +| TC-01 | 规则重命名后 RPM 计数器保持连续 | 使用稳定 `redis_key`,规则名变更后第二次请求仍被限流 | +| TC-02 | 模型变更后 RPM 计数器保持连续 | 使用稳定 `redis_key`,请求模型变更后第二次请求仍被限流 | +| TC-03 | 未配置 redis_key 时按规则名回退 | 无 `redis_key` 时,BFE 使用规则名生成 Redis key,行为向后兼容 | + +## 5.1 测试例详细说明 + +### TC-01 规则重命名后 RPM 计数器保持连续 + +**目的**:验证 `redis_key` 优先级高于规则名,规则重命名不会导致计数器重置。 + +**请求构造**: + +| 字段 | 值 | +|------|-----| +| Host | `ratelimit.example.org` | +| Path | `/v1/chat/completions` | +| Header | `Authorization: Bearer ak_ratelimit` | +| Body | `{"model":"gpt-4"}` | + +**执行步骤**: + +1. 启动 BFE,限流规则名为 `old-rule`,`redis_key = RL_RPM_rlp-0001_0`; +2. 发送第一次请求,预期 200; +3. 发送第二次请求,预期 429; +4. 停止并重启 BFE,限流规则名改为 `new-rule`,`redis_key` 保持不变; +5. 发送第三次请求,预期仍为 429。 + +**预期结果**: + +- 三次请求分别返回 200、429、429。 +- `cluster_ratelimit` mock backend 被命中 1 次。 + +### TC-02 模型变更后 RPM 计数器保持连续 + +**目的**:验证 `redis_key` 不变时,模型变更不会导致计数器重置。 + +**请求构造**: + +| 步骤 | Body | +|------|------| +| 1 | `{"model":"gpt-4"}` | +| 2 | `{"model":"gpt-4"}` | +| 3 | `{"model":"gpt-3.5"}` | + +**执行步骤**: + +1. 启动 BFE,限流规则模型为 `gpt-4`,`redis_key = RL_RPM_rlp-0001_0`; +2. 发送两次 `gpt-4` 请求,第一次 200,第二次 429; +3. 停止并重启 BFE,限流规则模型改为 `gpt-3.5`,`redis_key` 保持不变; +4. 发送 `gpt-3.5` 请求,预期仍为 429。 + +**预期结果**: + +- 三次请求分别返回 200、429、429。 +- `cluster_ratelimit` mock backend 被命中 1 次。 + +### TC-03 未配置 redis_key 时按规则名回退 + +**目的**:验证未配置 `redis_key` 时,BFE 回退到基于规则名生成 Redis key。 + +**请求构造**: + +| 字段 | 值 | +|------|-----| +| Host | `ratelimit.example.org` | +| Path | `/v1/chat/completions` | +| Header | `Authorization: Bearer ak_ratelimit` | +| Body | `{"model":"gpt-4"}` | + +**执行步骤**: + +1. 启动 BFE,限流规则名为 `name-based-rule`,不配置 `redis_key`; +2. 发送第一次请求,预期 200; +3. 发送第二次请求,预期 429。 + +**预期结果**: + +- 两次请求分别返回 200、429。 +- `cluster_ratelimit` mock backend 被命中 1 次。 + +## 6. 公共基础设施 + +本场景使用测试框架内置能力: + +1. **`common/redis_server.go`**:启动内存 Redis 服务,供 `mod_ai_rate_limit` 连接。 +2. **`common/bfe_config_builder.go`**:根据 `RateLimitPolicyData` 动态生成 `ai_rate_limit.data`。 + +## 7. 与其他场景的依赖关系 + +- 依赖 SC01 已验证的 `mod_ai_route` 路由表查找与绑定能力; +- 依赖 SC02 已验证的多 API-Key 基础能力; +- 不依赖 SC03 的 RMB 配额扣减逻辑。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC08-AI\346\250\241\345\236\213Fallback\351\207\215\347\256\227/TC-01-Fallback\344\273\216ClientModel\351\207\215\346\226\260\350\256\241\347\256\227.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC08-AI\346\250\241\345\236\213Fallback\351\207\215\347\256\227/TC-01-Fallback\344\273\216ClientModel\351\207\215\346\226\260\350\256\241\347\256\227.md" new file mode 100644 index 000000000..74de8f42a --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC08-AI\346\250\241\345\236\213Fallback\351\207\215\347\256\227/TC-01-Fallback\344\273\216ClientModel\351\207\215\346\226\260\350\256\241\347\256\227.md" @@ -0,0 +1,94 @@ +# TC-01 Fallback 从 ClientModel 重新计算 + +## 用例编号与名称 + +TC-01 Fallback 从 ClientModel 重新计算 + +## 所属场景 + +SC08 AI 模型 Fallback 重算 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证 fallback 时不会继承主 cluster 的 `TargetModel`,而是从原始 `ClientModel` 重新计算。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. mock 后端已启动: + - `cluster_openrouter` 返回 503,触发 fallback; + - `cluster_fallback` 返回 200; + - `cluster_default` 返回 200(本用例不命中)。 +3. 临时 BFE 配置已生成并加载。 + +## 配置构造 + +### cluster_openrouter.AIConf + +```json +{ + "Type": 0, + "MatchPrefix": "openrouter/", + "StripPrefix": true, + "ModelMapping": { + "modelA": "mapped-primary-model" + }, + "Keys": [ + {"Name": "primary-key", "Key": "sk-primary-key", "Weight": 100} + ] +} +``` + +### cluster_fallback + +TC-01 中 fallback cluster 不配置 `AIConf`,仅透传重新计算后的模型。 + +### ai_route.data 路由规则 + +| 规则名 | 条件 | targets | fallbacks | +|--------|------|---------|-----------| +| user_a-openrouter | `req_body_json_prefix_in("model", "clientprefix/", false)` | `cluster_openrouter`,Model 覆盖为 `openrouter/modelA` | `cluster_fallback`,Model 为空 | +| user_a-default | `default_t()` | `cluster_default` | 无 | + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `api.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"clientprefix/mymodel","messages":[{"role":"user","content":"hello"}]}` | + +## 主 cluster 模型计算 + +1. target Model 覆盖:`openrouter/modelA` +2. strip `openrouter/`:`modelA` +3. ModelMapping:`mapped-primary-model` + +## fallback cluster 模型计算 + +1. target Model 为空,保持 `ClientModel`:`clientprefix/mymodel` +2. 无 ModelMapping、无 strip + +## 预期结果 + +- 响应状态码:200。 +- `cluster_openrouter` mock backend 命中 1 次,收到的 model 为 `mapped-primary-model`。 +- `cluster_fallback` mock backend 命中 1 次,收到的 model 为 `clientprefix/mymodel`。 +- `cluster_default` mock backend 命中 0 次。 + +> 说明:若 fallback 继承主 cluster 的 `TargetModel`,fallback 将收到 `mapped-primary-model`,测试会失败。 + +## 清理 + +停止 `bfe` 进程、mock 后端,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC08-AI\346\250\241\345\236\213Fallback\351\207\215\347\256\227/TC-02-Fallback\345\272\224\347\224\250\350\207\252\350\272\253strip\351\205\215\347\275\256.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC08-AI\346\250\241\345\236\213Fallback\351\207\215\347\256\227/TC-02-Fallback\345\272\224\347\224\250\350\207\252\350\272\253strip\351\205\215\347\275\256.md" new file mode 100644 index 000000000..e19c116cf --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC08-AI\346\250\241\345\236\213Fallback\351\207\215\347\256\227/TC-02-Fallback\345\272\224\347\224\250\350\207\252\350\272\253strip\351\205\215\347\275\256.md" @@ -0,0 +1,96 @@ +# TC-02 Fallback 应用自身 strip 配置 + +## 用例编号与名称 + +TC-02 Fallback 应用自身 strip 配置 + +## 所属场景 + +SC08 AI 模型 Fallback 重算 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证 fallback cluster 不仅从 `ClientModel` 重算,还会应用自身的 `StripPrefix` 配置。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. mock 后端已启动: + - `cluster_openrouter` 返回 503,触发 fallback; + - `cluster_fallback` 返回 200。 +3. 临时 BFE 配置已生成并加载。 + +## 配置构造 + +### cluster_openrouter.AIConf + +```json +{ + "Type": 0, + "MatchPrefix": "openrouter/", + "StripPrefix": true, + "ModelMapping": { + "modelA": "mapped-primary-model" + }, + "Keys": [ + {"Name": "primary-key", "Key": "sk-primary-key", "Weight": 100} + ] +} +``` + +### cluster_fallback.AIConf + +```json +{ + "Type": 0, + "MatchPrefix": "clientprefix/", + "StripPrefix": true +} +``` + +### ai_route.data 路由规则 + +| 规则名 | 条件 | targets | fallbacks | +|--------|------|---------|-----------| +| user_a-openrouter | `req_body_json_prefix_in("model", "clientprefix/", false)` | `cluster_openrouter`,Model 覆盖为 `openrouter/modelA` | `cluster_fallback`,Model 为空 | +| user_a-default | `default_t()` | `cluster_default` | 无 | + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `api.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"clientprefix/mymodel","messages":[{"role":"user","content":"hello"}]}` | + +## 主 cluster 模型计算 + +1. target Model 覆盖:`openrouter/modelA` +2. strip `openrouter/`:`modelA` +3. ModelMapping:`mapped-primary-model` + +## fallback cluster 模型计算 + +1. target Model 为空,保持 `ClientModel`:`clientprefix/mymodel` +2. strip `clientprefix/`:`mymodel` + +## 预期结果 + +- 响应状态码:200。 +- `cluster_openrouter` mock backend 命中 1 次,收到的 model 为 `mapped-primary-model`。 +- `cluster_fallback` mock backend 命中 1 次,收到的 model 为 `mymodel`。 + +## 清理 + +停止 `bfe` 进程、mock 后端,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC08-AI\346\250\241\345\236\213Fallback\351\207\215\347\256\227/\345\234\272\346\231\257\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC08-AI\346\250\241\345\236\213Fallback\351\207\215\347\256\227/\345\234\272\346\231\257\350\257\264\346\230\216.md" new file mode 100644 index 000000000..5dcf2eab2 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC08-AI\346\250\241\345\236\213Fallback\351\207\215\347\256\227/\345\234\272\346\231\257\350\257\264\346\230\216.md" @@ -0,0 +1,176 @@ +# SC08 AI 模型 Fallback 重算 + +## 1. 场景背景与目的 + +在 AI 网关转发过程中,BFE 会根据 `mod_ai_route` 的 target/fallback 配置以及 cluster 的 `AIConf` 对请求体中的 `model` 字段进行改写。当主 cluster 失败触发 fallback 时,必须保证 fallback cluster 从原始 `ClientModel` 重新计算目标模型,而不是继承主 cluster 改写后的结果。本场景验证: + +- fallback 到新 cluster 时,`targetModel` 从 `aiMeta.ClientModel` 重新计算; +- fallback cluster 应用自身的 `ModelMapping`、`StripPrefix` 等配置,而不是主 cluster 的配置结果; +- `bodyModel` 缓存机制能正确避免重复改写请求体。 + +## 2. 运行模式 + +- **单组件模式**:仅启动真实 `bfe` 进程。 +- 不涉及 `ai-gateway-api` 与 `conf-agent`。 + +## 3. 涉及的 BFE 配置文件 + +| 文件 | 说明 | +|------|------| +| `bfe.conf` | 启用 `EnableAiGateway`,加载 `mod_ai_route` | +| `mod_ai_route/mod_ai_route.conf` | 指定 `ai_route.data` 路径 | +| `mod_ai_route/ai_route.data` | 定义 apikey 级路由规则,包含 target 与 fallback | +| `server_data_conf/host_rule.data` | 声明 `ai_product` 产品线对应的 Host | +| `server_data_conf/route_rule.data` | 默认路由 | +| `server_data_conf/vip_rule.data` | VIP 配置 | +| `cluster_conf/gslb.data` | GSLB 权重配置 | +| `cluster_conf/cluster_table.data` | 运行时根据 mock 后端地址动态生成 | +| `cluster_conf/cluster_conf.data` | 运行时根据 mock 后端动态生成,包含各 cluster 的 `AIConf` | + +## 4. 测试 Cluster 与 AIConf 配置 + +### 4.1 cluster_openrouter(主 cluster) + +```json +{ + "AIConf": { + "Type": 0, + "MatchPrefix": "openrouter/", + "StripPrefix": true, + "ModelMapping": { + "modelA": "mapped-primary-model" + }, + "Keys": [ + {"Name": "primary-key", "Key": "sk-primary-key", "Weight": 100} + ] + } +} +``` + +### 4.2 cluster_fallback(fallback cluster,TC-01 无 strip) + +TC-01 中 fallback cluster 不配置 `AIConf`,仅透传重新计算后的模型。 + +### 4.3 cluster_fallback(fallback cluster,TC-02 带 strip) + +```json +{ + "AIConf": { + "Type": 0, + "MatchPrefix": "clientprefix/", + "StripPrefix": true + } +} +``` + +### 4.4 cluster_default + +默认兜底 cluster,本场景不命中。 + +### 4.5 路由表设计 + +#### apikey_ak_user_a + +| 规则名 | 条件 | targets | fallbacks | +|--------|------|---------|-----------| +| user_a-openrouter | `req_body_json_prefix_in("model", "clientprefix/", false)` | `cluster_openrouter`,Model 覆盖为 `openrouter/modelA` | `cluster_fallback`,Model 为空 | +| user_a-default | `default_t()` | `cluster_default` | 无 | + +#### ApikeyRouteTableBindings + +```json +{ + "ak_user_a": ["apikey_ak_user_a"] +} +``` + +## 5. 测试例列表 + +| 编号 | 名称 | 核心验证点 | +|------|------|------------| +| TC-01 | Fallback 从 ClientModel 重新计算 | 主 cluster 失败后,fallback cluster 收到的是原始 client model,而不是主 cluster 的 mapped model | +| TC-02 | Fallback 应用自身 strip 配置 | fallback cluster 从 ClientModel 重新计算后,应用自身的 `StripPrefix` 配置 | + +## 5.1 测试例详细说明 + +### TC-01 Fallback 从 ClientModel 重新计算 + +**目的**:验证 fallback 时不会继承主 cluster 的 `TargetModel`。 + +**请求构造**: + +| 字段 | 值 | +|------|-----| +| Host | `api.example.org` | +| Path | `/v1/chat/completions` | +| Header | `Authorization: Bearer ak_user_a` | +| Body | `{"model":"clientprefix/mymodel","messages":[{"role":"user","content":"hello"}]}` | + +**Cluster 行为**: + +- `cluster_openrouter` 返回 503,触发 fallback; +- `cluster_fallback` 返回 200。 + +**主 cluster 模型计算**: + +1. target Model 覆盖:`openrouter/modelA` +2. strip `openrouter/`:`modelA` +3. ModelMapping:`mapped-primary-model` + +**fallback cluster 模型计算**: + +1. target Model 为空,保持 `ClientModel`:`clientprefix/mymodel` +2. 无 ModelMapping、无 strip + +**预期结果**: + +- 响应状态码 200。 +- `cluster_openrouter` mock backend 命中 1 次,收到的 model 为 `mapped-primary-model`。 +- `cluster_fallback` mock backend 命中 1 次,收到的 model 为 `clientprefix/mymodel`。 +- `cluster_default` mock backend 命中 0 次。 + +> 说明:若 fallback 继承主 cluster 的 `TargetModel`,fallback 将收到 `mapped-primary-model`,测试会失败。 + +### TC-02 Fallback 应用自身 strip 配置 + +**目的**:验证 fallback cluster 不仅从 `ClientModel` 重算,还会应用自身的 `StripPrefix` 配置。 + +**请求构造**: + +| 字段 | 值 | +|------|-----| +| Host | `api.example.org` | +| Path | `/v1/chat/completions` | +| Header | `Authorization: Bearer ak_user_a` | +| Body | `{"model":"clientprefix/mymodel","messages":[{"role":"user","content":"hello"}]}` | + +**Cluster 行为**: + +- `cluster_openrouter` 返回 503,触发 fallback; +- `cluster_fallback` 返回 200。 + +**主 cluster 模型计算**(同 TC-01):最终为 `mapped-primary-model`。 + +**fallback cluster 模型计算**: + +1. target Model 为空,保持 `ClientModel`:`clientprefix/mymodel` +2. strip `clientprefix/`:`mymodel` + +**预期结果**: + +- 响应状态码 200。 +- `cluster_openrouter` mock backend 命中 1 次,收到的 model 为 `mapped-primary-model`。 +- `cluster_fallback` mock backend 命中 1 次,收到的 model 为 `mymodel`。 + +## 6. 公共基础设施 + +本场景使用测试框架内置能力: + +1. **`common/mock_backend.go`**:记录 upstream 收到的请求 model,供断言使用。 +2. **`common/bfe_config_builder.go`**:根据 `AIConfs` 动态生成 `cluster_conf.data`。 + +## 7. 与其他场景的依赖关系 + +- 依赖 SC01 已验证的 `mod_ai_route` 路由表查找与 fallback 能力; +- 依赖 SC04 已验证的 ProviderModel 前缀裁剪能力; +- 不依赖 SC02 的 Key 级重试逻辑,本场景每个 cluster 仅配置单 Key。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" index 0c57b8a2b..09feea8f1 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" @@ -28,6 +28,9 @@ | SC03 | RMB 配额扣减 | 验证 `mod_ai_token_auth` 对 RMB 配额的定价匹配、定点数扣减、余额预检、ModelMapping/Fallback 计费行为,以及图像生成按次计费 | P0 | 11 | BFE RMB 配额支持、BFE 图像生成按次计费 | | SC04 | ProviderModel 前缀裁剪 | 验证 `AIConf.MatchPrefix` 与 `StripPrefix` 对请求模型名的裁剪行为 | P0 | 6 | BFE ProviderModel 前缀裁剪 | | SC05 | AI 访问日志字段校验 | 验证 `mod_access_pb3` 输出的 AI 可观测字段完整性与正确性,包括图像生成相关字段 | P0 | 10 | BFE 适配 bfe-access-pb AI 可观测字段升级、BFE 图像生成按次计费 | +| SC06 | Claude 协议支持 | 验证 Anthropic Claude Messages API 请求的识别、鉴权头注入、`anthropic-version` 补全及协议不匹配拒绝 | P0 | 8 | BFE Claude 协议支持 | +| SC07 | AI 限流 Redis 键 | 验证 `mod_ai_rate_limit` 使用显式 `redis_key` 进行限流计数,以及规则重命名/模型变更时的计数器连续性 | P0 | 3 | BFE AI 限流 Redis 键 | +| SC08 | AI 模型 Fallback 重算 | 验证 cluster 级 fallback 时,fallback cluster 从 `ClientModel` 重新计算目标模型,不继承主 cluster 的映射结果 | P0 | 2 | BFE ProviderModel 前缀裁剪 | ## 4. 公共前置条件 @@ -105,6 +108,34 @@ | TC-09 | Audio Token 字段 | P0 | | TC-10 | ImageGeneration 字段 | P0 | +### SC06 Claude 协议支持 + +| 测试例编号 | 测试例名称 | 优先级 | +|------------|------------|--------| +| TC-01 | OpenAI 风格转发 | P0 | +| TC-02 | Anthropic 风格转发 | P0 | +| TC-03 | Anthropic 请求命中 OpenAI-only 集群 | P0 | +| TC-04 | OpenAI 请求命中 Anthropic-only 集群 | P0 | +| TC-05 | 多协议集群同时支持两种请求 | P0 | +| TC-06 | 双头存在时 Authorization 优先 | P0 | +| TC-07 | 显式 anthropic-version 被保留 | P0 | +| TC-08 | Claude usage 字段解析 | P0 | + +### SC07 AI 限流 Redis 键 + +| 测试例编号 | 测试例名称 | 优先级 | +|------------|------------|--------| +| TC-01 | 规则重命名后 RPM 计数器保持连续 | P0 | +| TC-02 | 模型变更后 RPM 计数器保持连续 | P0 | +| TC-03 | 未配置 redis_key 时按规则名回退 | P0 | + +### SC08 AI 模型 Fallback 重算 + +| 测试例编号 | 测试例名称 | 优先级 | +|------------|------------|--------| +| TC-01 | Fallback 从 ClientModel 重新计算 | P0 | +| TC-02 | Fallback 应用自身 strip 配置 | P0 | + ## 6. 运行方式 ```bash From 61e532ff70838a92860919d591552bb77dc11485 Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Tue, 25 Aug 2026 14:43:34 +0800 Subject: [PATCH 10/18] feat: RMB tiered pricing support with peak/off-peak tiers and integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Tiers/TierPrices parsing and validation in cluster_conf - Update mod_ai_token_auth to select tier price based on request time - Add SC09 integration tests for peak tier, fallback, and cache billing - Add SC09 test design docs under 测试设计文档 - Update cluster_conf.data.md and rmb_quota.md documentation - Update overall scenario list to include SC09 --- .../cluster_conf/cluster_conf_load.go | 225 +++++- .../cluster_conf/cluster_conf_load_test.go | 313 +++++++- .../mod_ai_token_auth/mod_ai_token_auth.go | 28 +- .../mod_ai_token_auth_test.go | 119 +++ .../server_data_conf/cluster_conf.data.md | 727 +++++++++++++----- .../design-changes.md | 515 +++++++++++++ docs/zh_cn/sys_design/rmb_quota.md | 198 ++++- tests/integration/common/mock_backend.go | 2 +- .../sc09_rmb_tiered_pricing_test.go | 402 ++++++++++ .../testdata/bfe.conf | 38 + .../testdata/cluster_conf/gslb.data | 14 + .../testdata/mod_ai_route/ai_route.data | 40 + .../testdata/mod_ai_route/mod_ai_route.conf | 5 + .../mod_ai_token_auth/mod_ai_token_auth.conf | 13 + .../mod_body_process/body_process.data | 10 + .../mod_body_process/mod_body_process.conf | 5 + .../testdata/server_data_conf/host_rule.data | 14 + .../testdata/server_data_conf/route_rule.data | 12 + .../testdata/server_data_conf/vip_rule.data | 8 + .../testdata/tls_conf/backend_rs/bfe_i_ca.crt | 23 + .../testdata/tls_conf/backend_rs/bfe_r_ca.crt | 22 + .../tls_conf/backend_rs/r_bfe_dev.crt | 85 ++ .../tls_conf/backend_rs/r_bfe_dev_prv.pem | 27 + .../tls_conf/backend_rs/r_san_example.org.crt | 85 ++ .../backend_rs/r_san_example.org_prv.pem | 27 + .../testdata/tls_conf/certs/example.crt | 19 + .../testdata/tls_conf/certs/example.key | 27 + .../tls_conf/client_ca/example_ca.crt | 19 + .../tls_conf/client_ca/example_ca.key | 27 + .../testdata/tls_conf/server_cert_conf.data | 12 + .../testdata/tls_conf/session_ticket_key.data | 4 + .../testdata/tls_conf/tls_rule_conf.data | 20 + ...er\344\273\267\346\211\243\350\264\271.md" | 83 ++ ...67\346\240\274\346\211\243\350\264\271.md" | 78 ++ ...01\345\274\217\350\256\241\350\264\271.md" | 88 +++ ...01\345\274\217\350\256\241\350\264\271.md" | 86 +++ ...72\346\231\257\350\257\264\346\230\216.md" | 209 +++++ ...73\344\275\223\350\257\264\346\230\216.md" | 10 + 38 files changed, 3394 insertions(+), 245 deletions(-) create mode 100644 docs/zh_cn/modifications/2026-08-25-tiered-pricing-support/design-changes.md create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/sc09_rmb_tiered_pricing_test.go create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/bfe.conf create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/cluster_conf/gslb.data create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_ai_route/ai_route.data create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_ai_route/mod_ai_route.conf create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_ai_token_auth/mod_ai_token_auth.conf create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_body_process/body_process.data create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_body_process/mod_body_process.conf create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/server_data_conf/host_rule.data create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/server_data_conf/route_rule.data create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/server_data_conf/vip_rule.data create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/bfe_i_ca.crt create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/bfe_r_ca.crt create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_bfe_dev.crt create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_san_example.org.crt create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/certs/example.crt create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/certs/example.key create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/client_ca/example_ca.crt create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/client_ca/example_ca.key create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/server_cert_conf.data create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/session_ticket_key.data create mode 100644 tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/tls_rule_conf.data create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-01-peak-tier\351\235\236\346\265\201\345\274\217\346\214\211tier\344\273\267\346\211\243\350\264\271.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-02-\346\227\240Tiers\346\214\211\351\273\230\350\256\244\344\273\267\346\240\274\346\211\243\350\264\271.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-03-peak-tier\347\274\223\345\255\230\345\221\275\344\270\255\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-04-peak-tier\347\274\223\345\255\230\345\221\275\344\270\255SSE\346\265\201\345\274\217\350\256\241\350\264\271.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/\345\234\272\346\231\257\350\257\264\346\230\216.md" diff --git a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go index 556ca2171..799e8c30e 100644 --- a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go +++ b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go @@ -23,7 +23,9 @@ import ( "fmt" "os" "regexp" + "strconv" "strings" + "time" "github.com/bfenetworks/go-lib/log" "github.com/bfenetworks/go-lib/quota" @@ -142,6 +144,21 @@ type AIKeyPolicy struct { RetryBackoffMax int // ms } +// TimeRange defines a single time range within a week for a pricing tier. +// Weekdays uses Go's weekday convention: 0=Sunday, 1=Monday, ..., 6=Saturday. +// An empty Weekdays slice means the range applies every day. +type TimeRange struct { + Weekdays []int // 0=Sunday, 1=Monday ... 6=Saturday; empty means every day + Start string // "HH:MM" + End string // "HH:MM", must be > Start; cross-midnight ranges should be split +} + +// PriceTier defines a named pricing tier (e.g., "peak") with its time ranges. +type PriceTier struct { + Name string // tier name; initially only "peak" is supported + TimeRanges []TimeRange // hit any range means the request belongs to this tier +} + // ModelPrice represents a single model pricing entry in AIConf.ModelTable type ModelPrice struct { Provider string @@ -151,17 +168,28 @@ type ModelPrice struct { Capabilities []string SupportedParameters []string Limits map[string]interface{} - Prices map[string]float64 + Prices map[string]float64 // default prices + TierPrices map[string]map[string]float64 // tier name -> price table Metadata map[string]interface{} + + // pricesInt and tierPricesInt are built at config load time. + pricesInt map[string]int64 + tierPricesInt map[string]map[string]int64 } // ModelTable represents the cost/pricing table for a cluster type ModelTable struct { Currency string // fixed "RMB" in v0.4 + TimeZone string // default "Asia/Shanghai" + Tiers []PriceTier Models []ModelPrice // priceIndex is built at config load time: model -> mode -> *ModelPrice priceIndex map[string]map[string]*ModelPrice + // tierIndex is built at config load time: tier name -> *PriceTier + tierIndex map[string]*PriceTier + // tz is parsed from TimeZone at config load time. + tz *time.Location } type AIConf struct { @@ -203,6 +231,18 @@ const ( PriceOutputCostPerImageInt = "output_cost_per_image_int" ) +// priceKeyToIntKey maps the public price keys (used in config files) to the +// internal fixed-point integer keys used at runtime. +var priceKeyToIntKey = map[string]string{ + PriceInputCostPerToken: PriceInputCostPerTokenInt, + PriceOutputCostPerToken: PriceOutputCostPerTokenInt, + PriceCacheReadInputTokenCost: PriceCacheReadInputTokenCostInt, + PriceCacheCreationInputTokenCost: PriceCacheCreationInputTokenCostInt, + PriceInputCostPerAudioToken: PriceInputCostPerAudioTokenInt, + PriceOutputCostPerAudioToken: PriceOutputCostPerAudioTokenInt, + PriceOutputCostPerImage: PriceOutputCostPerImageInt, +} + func (conf *BackendHTTPS) GetProtocol() string { return conf.protocol } @@ -794,6 +834,89 @@ func AIConfCheck(conf *AIConf) error { return nil } +// containsInt reports whether vals contains target. +func containsInt(vals []int, target int) bool { + for _, v := range vals { + if v == target { + return true + } + } + return false +} + +// minutesFromHHMM parses "HH:MM" into minutes since midnight. +func minutesFromHHMM(s string) (int, error) { + parts := strings.Split(s, ":") + if len(parts) != 2 { + return 0, fmt.Errorf("invalid time format %s, expected HH:MM", s) + } + hour, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, fmt.Errorf("invalid hour in %s", s) + } + minute, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, fmt.Errorf("invalid minute in %s", s) + } + if hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return 0, fmt.Errorf("time %s out of range", s) + } + return hour*60 + minute, nil +} + +// timeRangesOverlap reports whether two time ranges overlap. +// Both ranges are within the same day and use half-open intervals [start, end). +func timeRangesOverlap(a, b TimeRange) bool { + if len(a.Weekdays) > 0 && len(b.Weekdays) > 0 { + sharedWeekday := false + for _, wd := range a.Weekdays { + if containsInt(b.Weekdays, wd) { + sharedWeekday = true + break + } + } + if !sharedWeekday { + return false + } + } + aStart, _ := minutesFromHHMM(a.Start) + aEnd, _ := minutesFromHHMM(a.End) + bStart, _ := minutesFromHHMM(b.Start) + bEnd, _ := minutesFromHHMM(b.End) + return aStart < bEnd && bStart < aEnd +} + +// validateTimeRanges checks a list of time ranges for a single tier. +func validateTimeRanges(tierName string, ranges []TimeRange) error { + if len(ranges) == 0 { + return fmt.Errorf("tier %s has no time_ranges", tierName) + } + for i, tr := range ranges { + for _, wd := range tr.Weekdays { + if wd < 0 || wd > 6 { + return fmt.Errorf("tier %s time_ranges[%d].weekdays contains invalid weekday %d", tierName, i, wd) + } + } + start, err := minutesFromHHMM(tr.Start) + if err != nil { + return fmt.Errorf("tier %s time_ranges[%d].start: %v", tierName, i, err) + } + end, err := minutesFromHHMM(tr.End) + if err != nil { + return fmt.Errorf("tier %s time_ranges[%d].end: %v", tierName, i, err) + } + if end <= start { + return fmt.Errorf("tier %s time_ranges[%d].end must be greater than start", tierName, i) + } + for j := 0; j < i; j++ { + if timeRangesOverlap(ranges[i], ranges[j]) { + return fmt.Errorf("tier %s time_ranges[%d] overlaps with time_ranges[%d]", tierName, i, j) + } + } + } + return nil +} + // ModelTableCheck checks and initializes ModelTable. // It converts float prices to fixed-point integers and builds priceIndex. func ModelTableCheck(table *ModelTable) error { @@ -805,6 +928,30 @@ func ModelTableCheck(table *ModelTable) error { return fmt.Errorf("currency must be %s", quota.UnitRMB) } + if table.TimeZone == "" { + table.TimeZone = "Asia/Shanghai" + } + loc, err := time.LoadLocation(table.TimeZone) + if err != nil { + return fmt.Errorf("invalid TimeZone %s: %v", table.TimeZone, err) + } + table.tz = loc + + table.tierIndex = make(map[string]*PriceTier) + for i := range table.Tiers { + tier := &table.Tiers[i] + if tier.Name == "" { + return errors.New("tier name is empty") + } + if tier.Name != "peak" { + return fmt.Errorf("unsupported tier name %s, only 'peak' is allowed", tier.Name) + } + if err := validateTimeRanges(tier.Name, tier.TimeRanges); err != nil { + return err + } + table.tierIndex[tier.Name] = tier + } + table.priceIndex = make(map[string]map[string]*ModelPrice) for i := range table.Models { @@ -829,13 +976,33 @@ func ModelTableCheck(table *ModelTable) error { return fmt.Errorf("negative price for model %s", price.Model) } - price.Prices[PriceInputCostPerTokenInt] = float64(quota.RmbToFixedPoint(input)) - price.Prices[PriceOutputCostPerTokenInt] = float64(quota.RmbToFixedPoint(output)) - price.Prices[PriceCacheReadInputTokenCostInt] = float64(quota.RmbToFixedPoint(cacheRead)) - price.Prices[PriceCacheCreationInputTokenCostInt] = float64(quota.RmbToFixedPoint(cacheWrite)) - price.Prices[PriceInputCostPerAudioTokenInt] = float64(quota.RmbToFixedPoint(audioInput)) - price.Prices[PriceOutputCostPerAudioTokenInt] = float64(quota.RmbToFixedPoint(audioOutput)) - price.Prices[PriceOutputCostPerImageInt] = float64(quota.RmbToFixedPoint(outputCostPerImage)) + price.pricesInt = make(map[string]int64) + price.pricesInt[PriceInputCostPerTokenInt] = quota.RmbToFixedPoint(input) + price.pricesInt[PriceOutputCostPerTokenInt] = quota.RmbToFixedPoint(output) + price.pricesInt[PriceCacheReadInputTokenCostInt] = quota.RmbToFixedPoint(cacheRead) + price.pricesInt[PriceCacheCreationInputTokenCostInt] = quota.RmbToFixedPoint(cacheWrite) + price.pricesInt[PriceInputCostPerAudioTokenInt] = quota.RmbToFixedPoint(audioInput) + price.pricesInt[PriceOutputCostPerAudioTokenInt] = quota.RmbToFixedPoint(audioOutput) + price.pricesInt[PriceOutputCostPerImageInt] = quota.RmbToFixedPoint(outputCostPerImage) + + price.tierPricesInt = make(map[string]map[string]int64) + for tierName, tierPriceMap := range price.TierPrices { + if tierName != "peak" { + return fmt.Errorf("unsupported tier name %s in TierPrices for model %s, only 'peak' is allowed", tierName, price.Model) + } + intMap := make(map[string]int64) + for key, val := range tierPriceMap { + if val < 0 { + return fmt.Errorf("negative tier price %s for model %s tier %s", key, price.Model, tierName) + } + intKey := key + if mapped, ok := priceKeyToIntKey[key]; ok { + intKey = mapped + } + intMap[intKey] = quota.RmbToFixedPoint(val) + } + price.tierPricesInt[tierName] = intMap + } if table.priceIndex[price.Model] == nil { table.priceIndex[price.Model] = make(map[string]*ModelPrice) @@ -849,6 +1016,48 @@ func ModelTableCheck(table *ModelTable) error { return nil } +// ActiveTierName returns the name of the tier active at the given time. +// If no tier matches, it returns an empty string (fallback to default prices). +func (table *ModelTable) ActiveTierName(now time.Time) string { + if table == nil || len(table.Tiers) == 0 || table.tz == nil { + return "" + } + t := now.In(table.tz) + wd := int(t.Weekday()) + cur := t.Hour()*60 + t.Minute() + + for i := range table.Tiers { + tier := &table.Tiers[i] + for _, tr := range tier.TimeRanges { + if len(tr.Weekdays) > 0 && !containsInt(tr.Weekdays, wd) { + continue + } + start, _ := minutesFromHHMM(tr.Start) + end, _ := minutesFromHHMM(tr.End) + if start <= cur && cur < end { + return tier.Name + } + } + } + return "" +} + +// GetPriceInt returns the fixed-point price for the given tier and key. +// If tier is empty or the tier/key is not configured, it falls back to default Prices. +func (p *ModelPrice) GetPriceInt(tier, key string) int64 { + if tier != "" && p.tierPricesInt != nil { + if tierMap, ok := p.tierPricesInt[tier]; ok { + if v, ok := tierMap[key]; ok { + return v + } + } + } + if p.pricesInt != nil { + return p.pricesInt[key] + } + return 0 +} + // LookupModelPrice looks up a model price entry by model and mode. // It returns nil if not found. func LookupModelPrice(table *ModelTable, model, mode string) *ModelPrice { diff --git a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go index c7909d669..446c0fcb2 100644 --- a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go +++ b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go @@ -16,6 +16,7 @@ package cluster_conf import ( "testing" + "time" ) func TestClusterConfLoad_1(t *testing.T) { @@ -144,11 +145,11 @@ func TestModelTableCheck(t *testing.T) { if entry == nil { t.Fatal("LookupModelPrice should return entry") } - if entry.Prices[PriceInputCostPerTokenInt] != 100 { - t.Errorf("input cost int = %v, want 100", entry.Prices[PriceInputCostPerTokenInt]) + if entry.GetPriceInt("", PriceInputCostPerTokenInt) != 100 { + t.Errorf("input cost int = %v, want 100", entry.GetPriceInt("", PriceInputCostPerTokenInt)) } - if entry.Prices[PriceOutputCostPerTokenInt] != 200 { - t.Errorf("output cost int = %v, want 200", entry.Prices[PriceOutputCostPerTokenInt]) + if entry.GetPriceInt("", PriceOutputCostPerTokenInt) != 200 { + t.Errorf("output cost int = %v, want 200", entry.GetPriceInt("", PriceOutputCostPerTokenInt)) } }) @@ -175,11 +176,11 @@ func TestModelTableCheck(t *testing.T) { if entry == nil { t.Fatal("LookupModelPrice should return entry") } - if entry.Prices[PriceCacheReadInputTokenCostInt] != 45 { - t.Errorf("cache read cost int = %v, want 45", entry.Prices[PriceCacheReadInputTokenCostInt]) + if entry.GetPriceInt("", PriceCacheReadInputTokenCostInt) != 45 { + t.Errorf("cache read cost int = %v, want 45", entry.GetPriceInt("", PriceCacheReadInputTokenCostInt)) } - if entry.Prices[PriceCacheCreationInputTokenCostInt] != 565 { - t.Errorf("cache write cost int = %v, want 565", entry.Prices[PriceCacheCreationInputTokenCostInt]) + if entry.GetPriceInt("", PriceCacheCreationInputTokenCostInt) != 565 { + t.Errorf("cache write cost int = %v, want 565", entry.GetPriceInt("", PriceCacheCreationInputTokenCostInt)) } }) @@ -206,11 +207,11 @@ func TestModelTableCheck(t *testing.T) { if entry == nil { t.Fatal("LookupModelPrice should return entry") } - if entry.Prices[PriceInputCostPerAudioTokenInt] != 2288 { - t.Errorf("audio input cost int = %v, want 2288", entry.Prices[PriceInputCostPerAudioTokenInt]) + if entry.GetPriceInt("", PriceInputCostPerAudioTokenInt) != 2288 { + t.Errorf("audio input cost int = %v, want 2288", entry.GetPriceInt("", PriceInputCostPerAudioTokenInt)) } - if entry.Prices[PriceOutputCostPerAudioTokenInt] != 4576 { - t.Errorf("audio output cost int = %v, want 4576", entry.Prices[PriceOutputCostPerAudioTokenInt]) + if entry.GetPriceInt("", PriceOutputCostPerAudioTokenInt) != 4576 { + t.Errorf("audio output cost int = %v, want 4576", entry.GetPriceInt("", PriceOutputCostPerAudioTokenInt)) } }) @@ -293,3 +294,291 @@ func TestAIConfCheck(t *testing.T) { } }) } + +func TestModelTableCheck_Tiers(t *testing.T) { + validPeakTier := PriceTier{ + Name: "peak", + TimeRanges: []TimeRange{ + {Weekdays: []int{1, 2, 3, 4, 5}, Start: "09:00", End: "12:00"}, + {Weekdays: []int{1, 2, 3, 4, 5}, Start: "14:00", End: "18:00"}, + }, + } + + t.Run("valid peak tier", func(t *testing.T) { + table := &ModelTable{ + Currency: "RMB", + TimeZone: "Asia/Shanghai", + Tiers: []PriceTier{validPeakTier}, + Models: []ModelPrice{ + { + Model: "m", + Mode: "chat", + Prices: map[string]float64{ + PriceInputCostPerToken: 1, + PriceOutputCostPerToken: 1, + }, + }, + }, + } + if err := ModelTableCheck(table); err != nil { + t.Fatalf("ModelTableCheck failed: %v", err) + } + if table.tz == nil { + t.Error("tz should be set") + } + if table.tierIndex["peak"] == nil { + t.Error("tierIndex should contain peak") + } + }) + + t.Run("default timezone", func(t *testing.T) { + table := &ModelTable{ + Currency: "RMB", + Tiers: []PriceTier{validPeakTier}, + Models: []ModelPrice{ + {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + }, + } + if err := ModelTableCheck(table); err != nil { + t.Fatalf("ModelTableCheck failed: %v", err) + } + if table.TimeZone != "Asia/Shanghai" { + t.Errorf("timezone = %s, want Asia/Shanghai", table.TimeZone) + } + }) + + t.Run("invalid timezone", func(t *testing.T) { + table := &ModelTable{ + Currency: "RMB", + TimeZone: "Mars/Phobos", + Tiers: []PriceTier{validPeakTier}, + Models: []ModelPrice{ + {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + }, + } + if err := ModelTableCheck(table); err == nil { + t.Error("expected error for invalid timezone") + } + }) + + t.Run("unsupported tier name", func(t *testing.T) { + table := &ModelTable{ + Currency: "RMB", + Tiers: []PriceTier{ + {Name: "off_peak", TimeRanges: []TimeRange{{Start: "00:00", End: "09:00"}}}, + }, + Models: []ModelPrice{ + {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + }, + } + if err := ModelTableCheck(table); err == nil { + t.Error("expected error for unsupported tier name") + } + }) + + t.Run("empty tier name", func(t *testing.T) { + table := &ModelTable{ + Currency: "RMB", + Tiers: []PriceTier{{Name: "", TimeRanges: []TimeRange{{Start: "00:00", End: "09:00"}}}}, + Models: []ModelPrice{ + {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + }, + } + if err := ModelTableCheck(table); err == nil { + t.Error("expected error for empty tier name") + } + }) + + t.Run("no time ranges", func(t *testing.T) { + table := &ModelTable{ + Currency: "RMB", + Tiers: []PriceTier{{Name: "peak"}}, + Models: []ModelPrice{ + {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + }, + } + if err := ModelTableCheck(table); err == nil { + t.Error("expected error for tier without time ranges") + } + }) + + t.Run("invalid weekday", func(t *testing.T) { + table := &ModelTable{ + Currency: "RMB", + Tiers: []PriceTier{ + {Name: "peak", TimeRanges: []TimeRange{{Weekdays: []int{7}, Start: "09:00", End: "12:00"}}}, + }, + Models: []ModelPrice{ + {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + }, + } + if err := ModelTableCheck(table); err == nil { + t.Error("expected error for invalid weekday") + } + }) + + t.Run("end before start", func(t *testing.T) { + table := &ModelTable{ + Currency: "RMB", + Tiers: []PriceTier{ + {Name: "peak", TimeRanges: []TimeRange{{Weekdays: []int{1}, Start: "12:00", End: "09:00"}}}, + }, + Models: []ModelPrice{ + {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + }, + } + if err := ModelTableCheck(table); err == nil { + t.Error("expected error when end <= start") + } + }) + + t.Run("overlapping time ranges", func(t *testing.T) { + table := &ModelTable{ + Currency: "RMB", + Tiers: []PriceTier{ + { + Name: "peak", + TimeRanges: []TimeRange{ + {Weekdays: []int{1}, Start: "09:00", End: "12:00"}, + {Weekdays: []int{1}, Start: "11:00", End: "14:00"}, + }, + }, + }, + Models: []ModelPrice{ + {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + }, + } + if err := ModelTableCheck(table); err == nil { + t.Error("expected error for overlapping time ranges") + } + }) + + t.Run("unsupported tier price name", func(t *testing.T) { + table := &ModelTable{ + Currency: "RMB", + Models: []ModelPrice{ + { + Model: "m", + Mode: "chat", + Prices: map[string]float64{ + PriceInputCostPerToken: 1, + PriceOutputCostPerToken: 1, + }, + TierPrices: map[string]map[string]float64{ + "off_peak": {PriceInputCostPerToken: 2}, + }, + }, + }, + } + if err := ModelTableCheck(table); err == nil { + t.Error("expected error for unsupported tier price name") + } + }) + + t.Run("negative tier price", func(t *testing.T) { + table := &ModelTable{ + Currency: "RMB", + Models: []ModelPrice{ + { + Model: "m", + Mode: "chat", + Prices: map[string]float64{ + PriceInputCostPerToken: 1, + PriceOutputCostPerToken: 1, + }, + TierPrices: map[string]map[string]float64{ + "peak": {PriceInputCostPerToken: -1}, + }, + }, + }, + } + if err := ModelTableCheck(table); err == nil { + t.Error("expected error for negative tier price") + } + }) +} + +func TestActiveTierName(t *testing.T) { + shanghai, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf("load location failed: %v", err) + } + + table := &ModelTable{ + Currency: "RMB", + TimeZone: "Asia/Shanghai", + Tiers: []PriceTier{ + { + Name: "peak", + TimeRanges: []TimeRange{ + {Weekdays: []int{1, 2, 3, 4, 5}, Start: "09:00", End: "12:00"}, + {Weekdays: []int{1, 2, 3, 4, 5}, Start: "14:00", End: "18:00"}, + }, + }, + }, + } + if err := ModelTableCheck(table); err != nil { + t.Fatalf("ModelTableCheck failed: %v", err) + } + + cases := []struct { + name string + moment time.Time + expected string + }{ + {"monday 10:00 peak", time.Date(2026, 8, 24, 10, 0, 0, 0, shanghai), "peak"}, + {"monday 13:00 off-peak", time.Date(2026, 8, 24, 13, 0, 0, 0, shanghai), ""}, + {"saturday 10:00 weekend", time.Date(2026, 8, 22, 10, 0, 0, 0, shanghai), ""}, + {"monday 18:00 half-open end", time.Date(2026, 8, 24, 18, 0, 0, 0, shanghai), ""}, + {"monday 09:00 half-open start", time.Date(2026, 8, 24, 9, 0, 0, 0, shanghai), "peak"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := table.ActiveTierName(c.moment); got != c.expected { + t.Errorf("ActiveTierName = %q, want %q", got, c.expected) + } + }) + } +} + +func TestGetPriceInt(t *testing.T) { + table := &ModelTable{ + Currency: "RMB", + Models: []ModelPrice{ + { + Model: "m", + Mode: "chat", + Prices: map[string]float64{ + PriceInputCostPerToken: 1, + PriceOutputCostPerToken: 2, + }, + TierPrices: map[string]map[string]float64{ + "peak": { + PriceInputCostPerToken: 10, + }, + }, + }, + }, + } + if err := ModelTableCheck(table); err != nil { + t.Fatalf("ModelTableCheck failed: %v", err) + } + entry := LookupModelPrice(table, "m", "chat") + if entry == nil { + t.Fatal("LookupModelPrice should return entry") + } + + if got := entry.GetPriceInt("", PriceInputCostPerTokenInt); got != 100000000 { + t.Errorf("default input cost = %d, want 100000000", got) + } + if got := entry.GetPriceInt("peak", PriceInputCostPerTokenInt); got != 1000000000 { + t.Errorf("peak input cost = %d, want 1000000000", got) + } + if got := entry.GetPriceInt("peak", PriceOutputCostPerTokenInt); got != 200000000 { + t.Errorf("peak output cost (fallback) = %d, want 200000000", got) + } + if got := entry.GetPriceInt("nonexistent", PriceInputCostPerTokenInt); got != 100000000 { + t.Errorf("nonexistent tier fallback = %d, want 100000000", got) + } +} diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go index 233247ed1..67918e1a1 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go @@ -17,6 +17,7 @@ package mod_ai_token_auth import ( "fmt" "net/url" + "time" "github.com/bfenetworks/go-lib/log" "github.com/bfenetworks/go-lib/quota" @@ -491,21 +492,26 @@ func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe return 0 } + tierName := "" + if cluster.AIConf.ModelTable != nil { + tierName = cluster.AIConf.ModelTable.ActiveTierName(time.Now()) + } + switch mode { case bfe_basic.ModeImageGeneration: - return calcImageGenerationCost(entry, usage) + return calcImageGenerationCost(entry, usage, tierName) default: - return calcChatCost(entry, usage) + return calcChatCost(entry, usage, tierName) } } -func calcImageGenerationCost(entry *cluster_conf.ModelPrice, usage *bfe_basic.TokenUsage) int64 { +func calcImageGenerationCost(entry *cluster_conf.ModelPrice, usage *bfe_basic.TokenUsage, tierName string) int64 { imageCount := usage.ImageCount if imageCount < 0 { imageCount = 0 } - costPerImage := int64(entry.Prices[cluster_conf.PriceOutputCostPerImageInt]) + costPerImage := entry.GetPriceInt(tierName, cluster_conf.PriceOutputCostPerImageInt) if costPerImage < 0 { log.Logger.Warn("invalid model price for image generation model %s", entry.Model) return 0 @@ -514,9 +520,9 @@ func calcImageGenerationCost(entry *cluster_conf.ModelPrice, usage *bfe_basic.To return imageCount * costPerImage } -func calcChatCost(entry *cluster_conf.ModelPrice, usage *bfe_basic.TokenUsage) int64 { - inputCost := int64(entry.Prices[cluster_conf.PriceInputCostPerTokenInt]) - outputCost := int64(entry.Prices[cluster_conf.PriceOutputCostPerTokenInt]) +func calcChatCost(entry *cluster_conf.ModelPrice, usage *bfe_basic.TokenUsage, tierName string) int64 { + inputCost := entry.GetPriceInt(tierName, cluster_conf.PriceInputCostPerTokenInt) + outputCost := entry.GetPriceInt(tierName, cluster_conf.PriceOutputCostPerTokenInt) if inputCost < 0 || outputCost < 0 { log.Logger.Warn("invalid model price for model %s", entry.Model) return 0 @@ -552,10 +558,10 @@ func calcChatCost(entry *cluster_conf.ModelPrice, usage *bfe_basic.TokenUsage) i audioOutputTokens = completionTokens } - cacheReadCost := int64(entry.Prices[cluster_conf.PriceCacheReadInputTokenCostInt]) - cacheWriteCost := int64(entry.Prices[cluster_conf.PriceCacheCreationInputTokenCostInt]) - audioInputCost := int64(entry.Prices[cluster_conf.PriceInputCostPerAudioTokenInt]) - audioOutputCost := int64(entry.Prices[cluster_conf.PriceOutputCostPerAudioTokenInt]) + cacheReadCost := entry.GetPriceInt(tierName, cluster_conf.PriceCacheReadInputTokenCostInt) + cacheWriteCost := entry.GetPriceInt(tierName, cluster_conf.PriceCacheCreationInputTokenCostInt) + audioInputCost := entry.GetPriceInt(tierName, cluster_conf.PriceInputCostPerAudioTokenInt) + audioOutputCost := entry.GetPriceInt(tierName, cluster_conf.PriceOutputCostPerAudioTokenInt) // normal input/output start as the full totals normalInput := promptTokens diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go index ba4ec163e..319899db0 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go @@ -1533,3 +1533,122 @@ func TestQuotaPlanDeduct(t *testing.T) { } }) } + +func buildTestClusterConfWithTiers(model string, inputCost, outputCost, cacheReadCost float64, + peakInputCost, peakOutputCost, peakCacheReadCost float64) *bfe_cluster.BfeCluster { + modelTable := &cluster_conf.ModelTable{ + Currency: "RMB", + TimeZone: "Asia/Shanghai", + Tiers: []cluster_conf.PriceTier{ + { + Name: "peak", + TimeRanges: []cluster_conf.TimeRange{ + {Weekdays: []int{1, 2, 3, 4, 5}, Start: "09:00", End: "12:00"}, + {Weekdays: []int{1, 2, 3, 4, 5}, Start: "14:00", End: "18:00"}, + }, + }, + }, + Models: []cluster_conf.ModelPrice{ + { + Model: model, + BaseModel: model, + Mode: "chat", + Prices: map[string]float64{ + cluster_conf.PriceInputCostPerToken: inputCost, + cluster_conf.PriceOutputCostPerToken: outputCost, + cluster_conf.PriceCacheReadInputTokenCost: cacheReadCost, + }, + TierPrices: map[string]map[string]float64{ + "peak": { + cluster_conf.PriceInputCostPerToken: peakInputCost, + cluster_conf.PriceOutputCostPerToken: peakOutputCost, + cluster_conf.PriceCacheReadInputTokenCost: peakCacheReadCost, + }, + }, + }, + }, + } + if err := cluster_conf.ModelTableCheck(modelTable); err != nil { + panic(fmt.Sprintf("ModelTableCheck failed: %v", err)) + } + + c := bfe_cluster.NewBfeCluster("test-cluster") + c.AIConf = &cluster_conf.AIConf{ + ModelTable: modelTable, + } + return c +} + +func TestCalcChatCost_Tier(t *testing.T) { + cluster := buildTestClusterConfWithTiers("deepseek-v4-pro", + 0.0000045, 0.0000135, 0.00000015, + 0.000009, 0.000027, 0.0000003) + entry := cluster.AIConf.ModelTable.Models[0] + + usage := &bfe_basic.TokenUsage{ + PromptTokens: 1000, + CompletionTokens: 500, + } + + // off-peak: 1000 * 4.5e-6 + 500 * 1.35e-5 + offPeakExpected := 1000*quota.RmbToFixedPoint(0.0000045) + 500*quota.RmbToFixedPoint(0.0000135) + if got := calcChatCost(&entry, usage, ""); got != offPeakExpected { + t.Errorf("off-peak cost = %d, want %d", got, offPeakExpected) + } + + // peak: 1000 * 9e-6 + 500 * 2.7e-5 + peakExpected := 1000*quota.RmbToFixedPoint(0.000009) + 500*quota.RmbToFixedPoint(0.000027) + if got := calcChatCost(&entry, usage, "peak"); got != peakExpected { + t.Errorf("peak cost = %d, want %d", got, peakExpected) + } +} + +func TestCalcChatCost_TierWithCache(t *testing.T) { + cluster := buildTestClusterConfWithTiers("deepseek-v4-pro", + 0.0000045, 0.0000135, 0.00000015, + 0.000009, 0.000027, 0.0000003) + entry := cluster.AIConf.ModelTable.Models[0] + + usage := &bfe_basic.TokenUsage{ + PromptTokens: 1000, + CompletionTokens: 500, + CacheReadTokens: 400, + } + + // peak with cache: 600 * 9e-6 + 400 * 3e-7 + 500 * 2.7e-5 + peakExpected := 600*quota.RmbToFixedPoint(0.000009) + 400*quota.RmbToFixedPoint(0.0000003) + 500*quota.RmbToFixedPoint(0.000027) + if got := calcChatCost(&entry, usage, "peak"); got != peakExpected { + t.Errorf("peak+cache cost = %d, want %d", got, peakExpected) + } + + // off-peak with cache: 600 * 4.5e-6 + 400 * 1.5e-7 + 500 * 1.35e-5 + offPeakExpected := 600*quota.RmbToFixedPoint(0.0000045) + 400*quota.RmbToFixedPoint(0.00000015) + 500*quota.RmbToFixedPoint(0.0000135) + if got := calcChatCost(&entry, usage, ""); got != offPeakExpected { + t.Errorf("off-peak+cache cost = %d, want %d", got, offPeakExpected) + } +} + +func TestCalcCostUnits_Tier(t *testing.T) { + m := NewModuleAITokenAuth() + clusterName := "deepseek-tiered" + model := "deepseek-v4-pro" + req := newTestRequestWithCluster("ak-123", "AI_product", clusterName, model) + cluster := buildTestClusterConfWithTiers(model, + 0.0000045, 0.0000135, 0, + 0.000009, 0.000027, 0) + req.SvrDataConf = &mockServerDataConf{clusters: map[string]*bfe_cluster.BfeCluster{clusterName: cluster}} + + usage := &bfe_basic.TokenUsage{ + PromptTokens: 1000, + CompletionTokens: 500, + } + + got := m.calcCostUnits(req, req.SvrDataConf, usage) + // Result depends on whether current Asia/Shanghai time hits peak tier. + // Just verify it is one of the two expected values. + offPeakExpected := 1000*quota.RmbToFixedPoint(0.0000045) + 500*quota.RmbToFixedPoint(0.0000135) + peakExpected := 1000*quota.RmbToFixedPoint(0.000009) + 500*quota.RmbToFixedPoint(0.000027) + if got != offPeakExpected && got != peakExpected { + t.Errorf("cost = %d, want either off-peak %d or peak %d", got, offPeakExpected, peakExpected) + } +} diff --git a/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md b/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md index e7375dc62..dedd510c7 100644 --- a/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md +++ b/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md @@ -1,144 +1,352 @@ # 集群转发配置 -## 配置简介 - -cluster_conf.data为集群转发配置文件。 - -## 配置描述 - -### 基础配置 - -| 配置项 | 类型 | 参数含义 | 必填 | 补充描述 | 合法性条件 | -| ---------- | ------ | ------------------------ | ---- | ------------------------------------------------------------ | ------------------------------------------------------------ | -| Version | String | 配置文件版本 | Y | 参见 [Version](../00-common.md#5-配置文件版本version) 类型定义 | 类型为 [Version](../00-common.md#5-配置文件版本version) | -| Config | Object | 各集群的转发配置参数 | Y | 键为集群名称,值为集群转发配置参数 | 非空 | -| Config[k] | String | 集群名称 | Y | 作为 Config 的键 | 非空 | -| Config[v] | Object | 集群转发配置参数 | Y | 包含 BackendConf、CheckConf、GslbBasic、ClusterBasic、HTTPSConf、AIConf 等 | 非空 | - -### 集群转发配置 - -注:以下配置项均位于名字空间Config[v], 在配置项名称中已省略 - -#### 后端基础配置 - -| 配置项 | 类型 | 参数含义 | 必填 | 补充描述 | 合法性条件 | -| ----------------------------------- | -------------- | ---------------------------------------------- | ---- | ------------------------------------------------------------ | ------------------------------------------------------------ | -| BackendConf.Protocol | String | 后端服务的协议 | N | 默认值`http` | 仅支持 `http`、`https`、`fcgi`、`tcp`、`ws`、`h2c` | -| BackendConf.TimeoutConnSrv | Integer | 连接后端的超时时间,单位是毫秒 | N | 默认值2000 | >= 0 | -| BackendConf.TimeoutResponseHeader | Integer | 从后端读响应头的超时时间,单位是毫秒 | N | 默认值60000 | >= 0 | -| BackendConf.MaxIdleConnsPerHost | Integer | BFE实例与每个后端的最大空闲长连接数 | N | 默认值2 | >= 0 | -| BackendConf.MaxConnsPerHost | Integer | BFE实例与每个后端的最大长连接数 | N | 默认值0;0代表无限制 | >= 0 | -| BackendConf.SlowStartTime | Integer | 后端实例慢启动时间,单位为秒 | N | 默认值0;0表示不开启慢启动 | >= 0 | -| BackendConf.RetryLevel | Integer | 请求重试级别 | N | 默认值0;0:连接后端失败时重试;1:连接后端失败、转发GET请求失败时均重试 | 仅支持 0 或 1 | -| BackendConf.OutlierDetectionHttpCode | String | 后端响应状态码异常检查 | N | 默认值 `""`,表示不开启检查;`"500"` 表示后端返回500则认为后端失败 | 类型为 [HTTPStatusCodePattern](../00-common.md#9-http-状态码模式httpstatuscodepattern);为空字符串表示不开启 | -| BackendConf.FCGIConf | Object | FastCGI 协议的配置 | N | 仅当 Protocol 为 `fcgi` 时生效 | - | -| BackendConf.FCGIConf.Root | String | 网站的Root文件夹位置 | 条件 | FCGIConf 配置时必填 | 非空 | -| BackendConf.FCGIConf.EnvVars | Map[string]string | 拓展的环境变量 | N | 自定义 FastCGI 环境变量 | - | - -#### 健康检查配置 - -| 配置项 | 类型 | 参数含义 | 必填 | 补充描述 | 合法性条件 | -| ----------------------------- | ------- | ---------------------------------------------- | ---- | ------------------------------------------------------------ | ------------------------------------------------------------ | -| CheckConf.Schem | String | 健康检查协议 | N | 默认值 `HTTP` | 仅支持 `HTTP`、`HTTPS`、`TCP`、`TLS` | -| CheckConf.HostType | String | 健康检查请求Host类型 | N | 默认值 `HOST`;`HOST` 使用 CheckConf.Host,`ADDR` 使用后端实例地址 | 仅支持 `HOST`、`ADDR` | -| CheckConf.Uri | String | 健康检查请求URI | N | 默认值 `"/health_check"` | - | -| CheckConf.Host | String | 健康检查请求HOST | N | 默认值 `""` | - | -| CheckConf.StatusCode | Integer | 期待返回的响应状态码 | N | 默认值 0,代表任意状态码均符合预期;也可配置为具体状态码如 200 | >= 0 | -| CheckConf.StatusCodeRange | String | 期待返回的响应状态码范围 | N | 具体参见注解「1. StatusCodeRange」 | 类型为 [HTTPStatusCodePattern](../00-common.md#9-http-状态码模式httpstatuscodepattern) | -| CheckConf.FailNum | Integer | 健康检查启动阈值 | N | 转发请求连续失败 FailNum 次后,将后端实例置为不可用状态,并启动健康检查;默认值5 | > 0 | -| CheckConf.SuccNum | Integer | 健康检查成功阈值 | N | 健康检查连续成功 SuccNum 次后,将后端实例置为可用状态;默认值1 | > 0 | -| CheckConf.CheckTimeout | Integer | 健康检查的超时时间,单位是毫秒 | N | 默认值0,表示无超时 | >= 0 | -| CheckConf.CheckInterval | Integer | 健康检查的间隔时间,单位是毫秒 | N | 默认值1000 | > 0 | - -#### GSLB基础配置 - -| 配置项 | 类型 | 参数含义 | 必填 | 补充描述 | 合法性条件 | -| ----------------------------------- | --------- | ---------------------------------------------- | ---- | ------------------------------------------------------------ | ------------------------------------------------------------ | -| GslbBasic.CrossRetry | Integer | 跨子集群最大重试次数 | N | 默认值0 | >= 0 | -| GslbBasic.RetryMax | Integer | 子集群内最大重试次数 | N | 默认值2 | >= 0 | -| GslbBasic.BalanceMode | String | 负载均衡模式 | N | 默认值`WRR` | 仅支持 `WRR`(加权轮询)、`WLC`(加权最小连接数)、`EPP`(基于外部策略的负载均衡) | -| GslbBasic.EPPAddr | []String | EPP服务端地址列表 | 条件 | 仅当 BalanceMode 为 `EPP` 时生效 | 非空列表;每个元素为有效地址 | -| GslbBasic.HashConf | Object | 会话保持的HASH策略配置 | N | - | - | -| GslbBasic.HashConf.HashStrategy | Integer | 会话保持的哈希策略 | N | 默认值为1(ClientIpOnly) | 仅支持 0(ClientIdOnly)、1(ClientIpOnly)、2(ClientIdPreferred)、3(RequestURI) | -| GslbBasic.HashConf.HashHeader | String | 会话保持的hash请求头 | N | 可选参数;可配置为能用于唯一区分一个客户端的Header;Cookie header 格式为 `"Cookie:key"` | - | -| GslbBasic.HashConf.SessionSticky | Boolean | 是否开启会话保持 | N | 默认值`False`;设为 `False` 时,会话保持级别为子集群级别 | - | - -#### 集群基础配置 - -| 配置项 | 类型 | 参数含义 | 必填 | 补充描述 | 合法性条件 | -| --------------------------------------- | ------- | ---------------------------------------------- | ---- | ------------------------------------------------------------ | ------------------------------------------------------------ | -| ClusterBasic.TimeoutReadClient | Integer | 读用户请求body的超时时间,单位为毫秒 | N | 默认值30000 | >= 0 | -| ClusterBasic.TimeoutWriteClient | Integer | 写响应的超时时间,单位为毫秒 | N | 默认值60000 | >= 0 | -| ClusterBasic.TimeoutReadClientAgain | Integer | 连接闲置超时时间,单位为毫秒 | N | 默认值60000 | >= 0 | -| ClusterBasic.ReqWriteBufferSize | Integer | 请求的写buffer大小,单位为Bytes | N | 默认值512;建议使用默认值 | > 0 | -| ClusterBasic.ReqFlushInterval | Integer | 刷新请求的间隔时间,单位是毫秒 | N | 默认值为0,表示不进行周期性刷新 | >= 0 | -| ClusterBasic.ResFlushInterval | Integer | 刷新响应的间隔时间,单位是毫秒 | N | 默认值为-1,表示不对响应进行缓存;设置为0表示不进行周期性刷新;建议使用默认值 | - | -| ClusterBasic.CancelOnClientClose | Boolean | 当服务端正在读后端响应时,如果客户端断连,是否取消该阻塞状态 | N | 默认值为`false`;建议使用默认值 | - | -| ClusterBasic.DisableHostHeader | Boolean | 是否禁用由BFE自动添加/覆盖的Host请求头 | N | 默认值为`false` | - | -| ClusterBasic.DisableHealthCheck | Boolean | 是否禁用该集群的健康检查 | N | 默认值为`false` | - | - -#### 后端服务HTTPS配置 - -| 配置项 | 类型 | 参数含义 | 必填 | 补充描述 | 合法性条件 | -| ---------------------------------- | --------- | ---------------------------------------------- | ---- | ------------------------------------------------------------ | ------------------------------------------------------------ | -| HTTPSConf.RSHost | String | 后端服务实例的hostname | N | 用来验证服务端证书;无默认值,需显式配置 | 非空;须为有效主机名 | -| HTTPSConf.BFEKeyFile | String | 私钥文件路径 | 条件 | 支持双向认证时必填;BFE引擎向后端转发https请求时使用的私钥;须为pem格式 | 类型为 [FilePath](../00-common.md#3-文件路径filepath);`RSInsecureSkipVerify=false` 且需要双向认证时必填 | -| HTTPSConf.BFECertFile | String | 证书文件路径 | 条件 | 支持双向认证时必填;须为符合x509标准的pem格式,每个pem文件只能包含一张证书 | 类型为 [FilePath](../00-common.md#3-文件路径filepath);`RSInsecureSkipVerify=false` 且需要双向认证时必填 | -| HTTPSConf.RSCAList | []String | 后端服务端证书CA列表 | 条件 | `BackendConf.Protocol` 为 `https` 且需要验证服务端证书时必填;不填则使用系统默认CA池 | 每个元素类型为 [FilePath](../00-common.md#3-文件路径filepath);须为符合x509标准的pem格式证书 | -| HTTPSConf.RSInsecureSkipVerify | Boolean | 服务端证书验证开关 | N | 默认值为`false` | - | - -#### AI服务配置 - -| 配置项 | 类型 | 参数含义 | 必填 | 补充描述 | 合法性条件 | -| ----------------------------------- | ----------------- | ---------------------------------------------- | ---- | ------------------------------------------------------------ | ------------------------------------------------------------ | -| AIConf.Type | Integer | AI服务类型 | N | 当前保留字段,请保持为0 | 仅支持 0 | -| AIConf.Provider | String | 该集群在 model_prices 中对应的 provider 名称 | N | 由 ai-gateway-api 根据 OpenAPI `llm_config.provider` 自动填充;用于成本统计 | - | -| AIConf.Keys | []Object | 后端大模型服务的 API-Key 列表 | N | 为空数组表示访问后端服务时不注入 API-Key,仍保持请求的 API-Key;按权重加权随机选择 | 元素见下表「AIConf.Keys 元素」 | -| AIConf.KeyPolicy | Object | API-Key 选择策略与重试退避配置 | N | 多 Key 场景下生效;单 Key 或无 Key 时退避逻辑不生效 | 元素见下表「AIConf.KeyPolicy 元素」 | -| AIConf.ModelMapping | Map[string]string | 原请求model -> 后端服务的model 的映射关系 | N | 访问后端服务时将根据请求的 model 字段查找此映射关系,命中则重写请求的 model 字段 | 键值均非空 | -| AIConf.MatchPrefix | String | 需要匹配的 provider/model 前缀 | N | 例如 `openrouter/`;必须以 `/` 结尾;用于 OpenRouter 等聚合 provider 场景 | `StripPrefix=true` 时必填 | -| AIConf.StripPrefix | Boolean | 是否裁剪 `MatchPrefix` 指定前缀 | N | `true` 时转发给下游前会从请求 model 字段中去掉该前缀;`false` 时仅用于路由标识,不裁剪 | 默认 `false` | -| AIConf.ModelProtocols | []String | 该集群 provider 支持的模型访问协议列表 | N | 例如 `["openai"]`、`["anthropic"]`、`["openai", "anthropic"]`;为空时默认仅支持 `openai` | 元素取值须为 `openai` 或 `anthropic` | -| AIConf.ModelTable | Object | 该集群的模型定价表 | N | 由 ai-gateway-api 根据 `Provider` 查询 model_prices 自动填充;当前货币固定为 RMB | 元素见下表「AIConf.ModelTable 元素」 | - -##### AIConf.Keys 元素 - -| 配置项 | 类型 | 参数含义 | 必填 | 补充描述 | 合法性条件 | -| ------------------- | ------- | ------------------ | ---- | ------------------------------------------------ | ---------- | -| AIConf.Keys[i].Name | String | API-Key 名称/标识 | Y | 用于日志、监控、运维识别 | 非空 | -| AIConf.Keys[i].Key | String | API-Key 值 | Y | 实际用于后端认证的密钥 | 非空 | -| AIConf.Keys[i].Weight | Integer | 权重 | Y | 用于加权随机选择;范围为 `[0,100]`;`0` 表示不接收流量 | `[0,100]`;多 Key 时权重总和须为 100 | - -##### AIConf.KeyPolicy 元素 - -| 配置项 | 类型 | 参数含义 | 必填 | 补充描述 | 合法性条件 | -| ------------------------------------- | ------- | -------------------- | ---- | ------------------------------------------------------------ | -------------------------------- | -| AIConf.KeyPolicy.Strategy | String | Key 选择策略 | N | 当前仅支持 `weighted_random` | 仅支持 `weighted_random` | -| AIConf.KeyPolicy.MaxRetries | Integer | 总额外重试次数 | N | 一次 `aiClusterInvoke` 调用内,除首次选择外的最大重试次数;`0` 表示不重试 | >= 0 | -| AIConf.KeyPolicy.RetryBackoffInitial | Integer | 初始退避时间,单位毫秒 | N | 首次重试的退避时间 | >= 0 | -| AIConf.KeyPolicy.RetryBackoffMax | Integer | 最大退避时间,单位毫秒 | N | 退避时间上限 | >= 0,且须 >= RetryBackoffInitial | - -##### AIConf.ModelTable 元素 - -| 配置项 | 类型 | 参数含义 | 必填 | 补充描述 | 合法性条件 | -| ------------------------------- | -------- | ------------------ | ---- | ----------------------------------- | ---------- | -| AIConf.ModelTable.Currency | String | 货币类型 | Y | v0.4 固定为 `RMB` | - | -| AIConf.ModelTable.Models | []Object | 模型定价条目列表 | Y | 每个条目对应一个模型及其价格/限制 | 元素见下表「AIConf.ModelTable.Models 元素」 | - -##### AIConf.ModelTable.Models 元素 - -| 配置项 | 类型 | 参数含义 | 必填 | 补充描述 | 合法性条件 | -| ---------------------------------------------- | ----------------- | ------------------ | ---- | ----------------------------------- | ---------- | -| AIConf.ModelTable.Models[i].Provider | String | Provider 名 | Y | - | 非空 | -| AIConf.ModelTable.Models[i].Model | String | 模型名 | Y | 用于匹配请求中的 target_model | 非空 | -| AIConf.ModelTable.Models[i].BaseModel | String | 归一化模型名 | Y | - | 非空 | -| AIConf.ModelTable.Models[i].Mode | String | 请求模式 | N | 例如 `chat` | - | -| AIConf.ModelTable.Models[i].Capabilities | []String | 能力列表 | N | 例如 `["chat", "reasoning"]` | - | -| AIConf.ModelTable.Models[i].SupportedParameters| []String | 支持的请求参数列表 | N | 例如 `["temperature", "max_tokens"]`| - | -| AIConf.ModelTable.Models[i].Limits | Map[string]Integer| 限制对象 | N | 例如 `context_window` 等 | - | -| AIConf.ModelTable.Models[i].Prices | Map[string]Number | 价格对象 | N | 例如 `input_cost_per_token` 等 | - | - -## 配置示例 +## 1. 配置简介 + +`cluster_conf.data` 是 BFE 的集群转发配置文件,用于声明各集群的后端、健康检查、负载均衡、AI 服务接入等参数。 + +--- + +## 2. 顶层结构 + +```json +{ + "Version": "20190101000000", + "Config": { /* 集群名称 -> 集群转发配置 */ } +} +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| Version | string | Y | 配置文件版本,通常采用时间戳格式 | 参见 [Version](../00-common.md#5-配置文件版本version) | +| Config | object | Y | 各集群的转发配置参数;键为集群名称,值为集群转发配置 | 非空 | + +--- + +## 3. 集群转发配置 + +以下配置项均位于 `Config[]` 名字空间下。 + +```json +{ + "BackendConf": { /* 后端基础配置 */ }, + "CheckConf": { /* 健康检查配置 */ }, + "GslbBasic": { /* GSLB 基础配置 */ }, + "ClusterBasic": { /* 集群基础配置 */ }, + "HTTPSConf": { /* 后端 HTTPS 配置,可选 */ }, + "AIConf": { /* AI 服务配置,可选 */ } +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| BackendConf | object | N | 后端基础配置 | +| CheckConf | object | N | 健康检查配置 | +| GslbBasic | object | N | GSLB 基础配置 | +| ClusterBasic | object | N | 集群基础配置 | +| HTTPSConf | object | N | 后端服务 HTTPS 配置;仅当 `BackendConf.Protocol` 为 `https` 时生效 | +| AIConf | object | N | AI 服务配置;用于大模型转发、API-Key 选择、模型定价等 | + +--- + +## 4. 后端基础配置 + +```json +{ + "BackendConf": { + "Protocol": "http", + "TimeoutConnSrv": 2000, + "TimeoutResponseHeader": 60000, + "MaxIdleConnsPerHost": 2, + "MaxConnsPerHost": 0, + "SlowStartTime": 0, + "RetryLevel": 0, + "OutlierDetectionHttpCode": "", + "FCGIConf": { /* 仅 Protocol=fcgi 时生效 */ } + } +} +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| Protocol | string | N | 后端服务的协议;默认 `http` | 仅支持 `http`、`https`、`fcgi`、`tcp`、`ws`、`h2c` | +| TimeoutConnSrv | integer | N | 连接后端的超时时间,单位毫秒;默认 `2000` | >= 0 | +| TimeoutResponseHeader | integer | N | 从后端读响应头的超时时间,单位毫秒;默认 `60000` | >= 0 | +| MaxIdleConnsPerHost | integer | N | BFE 实例与每个后端的最大空闲长连接数;默认 `2` | >= 0 | +| MaxConnsPerHost | integer | N | BFE 实例与每个后端的最大长连接数;默认 `0`,表示无限制 | >= 0 | +| SlowStartTime | integer | N | 后端实例慢启动时间,单位秒;默认 `0`,表示不开启 | >= 0 | +| RetryLevel | integer | N | 请求重试级别;默认 `0` | `0`:连接后端失败时重试;`1`:连接后端失败、转发 GET 请求失败时均重试 | +| OutlierDetectionHttpCode | string | N | 后端响应状态码异常检查;默认 `""`,表示不开启 | 类型为 [HTTPStatusCodePattern](../00-common.md#9-http-状态码模式httpstatuscodepattern) | +| FCGIConf | object | N | FastCGI 协议的配置;仅当 `Protocol` 为 `fcgi` 时生效 | - | +| FCGIConf.Root | string | 条件 | FastCGI Root 文件夹位置;`FCGIConf` 配置时必填 | 非空 | +| FCGIConf.EnvVars | map[string]string | N | 自定义 FastCGI 环境变量 | - | + +--- + +## 5. 健康检查配置 + +```json +{ + "CheckConf": { + "Schem": "HTTP", + "HostType": "HOST", + "Uri": "/health_check", + "Host": "", + "StatusCode": 0, + "StatusCodeRange": "", + "FailNum": 5, + "SuccNum": 1, + "CheckTimeout": 0, + "CheckInterval": 1000 + } +} +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| Schem | string | N | 健康检查协议;默认 `HTTP` | 仅支持 `HTTP`、`HTTPS`、`TCP`、`TLS` | +| HostType | string | N | 健康检查请求 Host 类型;默认 `HOST` | `HOST` 使用 `CheckConf.Host`;`ADDR` 使用后端实例地址 | +| Uri | string | N | 健康检查请求 URI;默认 `"/health_check"` | - | +| Host | string | N | 健康检查请求 HOST;默认 `""` | - | +| StatusCode | integer | N | 期待返回的响应状态码;默认 `0`,表示任意状态码均符合预期 | >= 0 | +| StatusCodeRange | string | N | 期待返回的响应状态码范围 | 类型为 [HTTPStatusCodePattern](../00-common.md#9-http-状态码模式httpstatuscodepattern) | +| FailNum | integer | N | 转发请求连续失败 `FailNum` 次后,将后端置为不可用并启动健康检查;默认 `5` | > 0 | +| SuccNum | integer | N | 健康检查连续成功 `SuccNum` 次后,将后端置为可用;默认 `1` | > 0 | +| CheckTimeout | integer | N | 健康检查的超时时间,单位毫秒;默认 `0`,表示无超时 | >= 0 | +| CheckInterval | integer | N | 健康检查的间隔时间,单位毫秒;默认 `1000` | > 0 | + +--- + +## 6. GSLB 基础配置 + +```json +{ + "GslbBasic": { + "CrossRetry": 0, + "RetryMax": 2, + "BalanceMode": "WRR", + "EPPAddr": [], + "HashConf": { + "HashStrategy": 1, + "HashHeader": "", + "SessionSticky": false + } + } +} +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| CrossRetry | integer | N | 跨子集群最大重试次数;默认 `0` | >= 0 | +| RetryMax | integer | N | 子集群内最大重试次数;默认 `2` | >= 0 | +| BalanceMode | string | N | 负载均衡模式;默认 `WRR` | `WRR`(加权轮询)、`WLC`(加权最小连接数)、`EPP`(基于外部策略) | +| EPPAddr | []string | 条件 | EPP 服务端地址列表;`BalanceMode` 为 `EPP` 时必填 | 非空列表;每个元素为有效地址 | +| HashConf | object | N | 会话保持的 HASH 策略配置 | - | +| HashConf.HashStrategy | integer | N | 哈希策略;默认 `1`(ClientIpOnly) | `0` ClientIdOnly;`1` ClientIpOnly;`2` ClientIdPreferred;`3` RequestURI | +| HashConf.HashHeader | string | N | 会话保持的 hash 请求头;Cookie 格式为 `"Cookie:key"` | - | +| HashConf.SessionSticky | boolean | N | 是否开启会话保持;默认 `false` | - | + +--- + +## 7. 集群基础配置 + +```json +{ + "ClusterBasic": { + "TimeoutReadClient": 30000, + "TimeoutWriteClient": 60000, + "TimeoutReadClientAgain": 60000, + "ReqWriteBufferSize": 512, + "ReqFlushInterval": 0, + "ResFlushInterval": -1, + "CancelOnClientClose": false, + "DisableHostHeader": false, + "DisableHealthCheck": false + } +} +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| TimeoutReadClient | integer | N | 读用户请求 body 的超时时间,单位毫秒;默认 `30000` | >= 0 | +| TimeoutWriteClient | integer | N | 写响应的超时时间,单位毫秒;默认 `60000` | >= 0 | +| TimeoutReadClientAgain | integer | N | 连接闲置超时时间,单位毫秒;默认 `60000` | >= 0 | +| ReqWriteBufferSize | integer | N | 请求的写 buffer 大小,单位 Bytes;默认 `512` | > 0 | +| ReqFlushInterval | integer | N | 刷新请求的间隔时间,单位毫秒;默认 `0` | >= 0 | +| ResFlushInterval | integer | N | 刷新响应的间隔时间,单位毫秒;默认 `-1` | - | +| CancelOnClientClose | boolean | N | 当服务端正在读后端响应时,客户端断连是否取消阻塞;默认 `false` | - | +| DisableHostHeader | boolean | N | 是否禁用 BFE 自动添加/覆盖的 Host 请求头;默认 `false` | - | +| DisableHealthCheck | boolean | N | 是否禁用该集群的健康检查;默认 `false` | - | + +--- + +## 8. 后端服务 HTTPS 配置 + +```json +{ + "HTTPSConf": { + "RSHost": "www.example.org", + "BFEKeyFile": "", + "BFECertFile": "", + "RSCAList": [], + "RSInsecureSkipVerify": false + } +} +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| RSHost | string | N | 后端服务实例的 hostname;用于验证服务端证书 | 非空;须为有效主机名 | +| BFEKeyFile | string | 条件 | BFE 向后端转发 HTTPS 请求时使用的私钥文件;双向认证时必填 | 类型为 [FilePath](../00-common.md#3-文件路径filepath) | +| BFECertFile | string | 条件 | BFE 向后端转发 HTTPS 请求时使用的证书文件;双向认证时必填 | 类型为 [FilePath](../00-common.md#3-文件路径filepath) | +| RSCAList | []string | 条件 | 后端服务端证书 CA 列表;`BackendConf.Protocol` 为 `https` 且需要验证服务端证书时必填 | 每个元素为有效的 pem 格式证书路径 | +| RSInsecureSkipVerify | boolean | N | 是否跳过服务端证书验证;默认 `false` | - | + +--- + +## 9. AI 服务配置 + +`AIConf` 用于声明大模型后端的接入信息、API-Key 策略、模型映射与定价表。通常由 `ai-gateway-api` 自动生成并下发。 + +```json +{ + "AIConf": { + "Type": 0, + "Provider": "deepseek", + "MatchPrefix": "", + "StripPrefix": false, + "ModelProtocols": ["openai"], + "Keys": [ /* AIConf.Keys 元素 */ ], + "KeyPolicy": { /* AIConf.KeyPolicy 元素 */ }, + "ModelMapping": {}, + "ModelTable": { /* AIConf.ModelTable 元素 */ } + } +} +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| Type | integer | N | AI 服务类型;当前保留字段,请保持为 `0` | 仅支持 `0` | +| Provider | string | N | 该集群在 `model_prices` 中对应的 provider 名称;用于成本统计 | - | +| Keys | []object | N | 后端大模型服务的 API-Key 列表 | 元素见 [9.1 AIConf.Keys 元素](#91-aiconfkeys-元素) | +| KeyPolicy | object | N | API-Key 选择策略与重试退避配置 | 元素见 [9.2 AIConf.KeyPolicy 元素](#92-aiconfkeypolicy-元素) | +| ModelMapping | map[string]string | N | 原请求 model -> 后端服务 model 的映射;命中则重写请求 model | 键值均非空 | +| MatchPrefix | string | N | 需要匹配的 provider/model 前缀;用于 OpenRouter 等聚合 provider 场景 | `StripPrefix=true` 时必填;须以 `/` 结尾 | +| StripPrefix | boolean | N | 是否裁剪 `MatchPrefix` 指定前缀;默认 `false` | - | +| ModelProtocols | []string | N | 该集群 provider 支持的模型访问协议列表;为空时默认仅支持 `openai` | 元素取值须为 `openai` 或 `anthropic` | +| ModelTable | object | N | 该集群的模型定价表;当前货币固定为 `RMB` | 元素见 [9.3 AIConf.ModelTable 元素](#93-aiconfmodeltable-元素) | + +### 9.1 AIConf.Keys 元素 + +```json +{ + "Name": "key-primary", + "Key": "sk-example-api-key", + "Weight": 100 +} +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| Name | string | Y | API-Key 名称/标识,用于日志、监控、运维识别 | 非空 | +| Key | string | Y | 实际用于后端认证的密钥 | 非空 | +| Weight | integer | Y | 加权随机选择时的权重;`0` 表示不接收流量 | `[0, 100]`;多 Key 时权重总和须为 `100` | + +### 9.2 AIConf.KeyPolicy 元素 + +```json +{ + "Strategy": "weighted_random", + "MaxRetries": 3, + "RetryBackoffInitial": 500, + "RetryBackoffMax": 5000 +} +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| Strategy | string | N | Key 选择策略;当前仅支持 `weighted_random` | 仅支持 `weighted_random` | +| MaxRetries | integer | N | 一次 `aiClusterInvoke` 调用内,除首次选择外的最大重试次数;`0` 表示不重试 | >= 0 | +| RetryBackoffInitial | integer | N | 首次重试的退避时间,单位毫秒 | >= 0 | +| RetryBackoffMax | integer | N | 最大退避时间,单位毫秒 | >= 0,且须 >= `RetryBackoffInitial` | + +### 9.3 AIConf.ModelTable 元素 + +```json +{ + "Currency": "RMB", + "TimeZone": "Asia/Shanghai", + "Tiers": [ /* AIConf.ModelTable.Tiers 元素 */ ], + "Models": [ /* AIConf.ModelTable.Models 元素 */ ] +} +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| Currency | string | Y | 货币类型;当前固定为 `RMB` | - | +| TimeZone | string | N | 计算时段所使用的时区;默认 `Asia/Shanghai` | 须为合法 IANA 时区名 | +| Tiers | []object | N | 时段 tier 定义列表;不填时按固定价格计费 | 元素见 [9.3.1 Tiers 元素](#931-tiers-元素) | +| Models | []object | Y | 模型定价条目列表 | 元素见 [9.3.2 Models 元素](#932-models-元素) | + +#### 9.3.1 Tiers 元素 + +```json +{ + "Name": "peak", + "TimeRanges": [ + { "Weekdays": [1, 2, 3, 4, 5], "Start": "09:00", "End": "12:00" }, + { "Weekdays": [1, 2, 3, 4, 5], "Start": "14:00", "End": "18:00" } + ] +} +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| Name | string | Y | Tier 名称;**初期只支持 `peak`** | 非空;当前仅支持 `peak` | +| TimeRanges | []object | Y | 该 tier 生效的时间范围列表;命中任意一个即属于该 tier;按列表顺序匹配 | 元素见 [9.3.1.1 TimeRanges 元素](#9311-timeranges-元素) | + +##### 9.3.1.1 TimeRanges 元素 + +```json +{ "Weekdays": [1, 2, 3, 4, 5], "Start": "09:00", "End": "12:00" } +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| Weekdays | []integer | N | 生效的星期几;`0`=周日,`1`=周一,...,`6`=周六;为空表示每天 | 元素取值范围 `0-6` | +| Start | string | Y | 开始时间,格式 `HH:MM` | 合法时间格式;须 < `End` | +| End | string | Y | 结束时间,格式 `HH:MM`;区间为左闭右开 `[Start, End)` | 合法时间格式;须 > `Start` | + +#### 9.3.2 Models 元素 + +```json +{ + "Provider": "deepseek", + "Model": "deepseek-v4-pro", + "BaseModel": "deepseek-v4-pro", + "Mode": "chat", + "Capabilities": ["chat", "reasoning", "tools", "prompt_caching"], + "SupportedParameters": ["temperature", "max_tokens"], + "Limits": { /* 限制对象 */ }, + "Prices": { /* 默认价格 */ }, + "TierPrices": { /* 分时段价格 */ } +} +``` + +| 字段 | 类型 | 必填 | 说明 | 合法性条件 | +|------|------|------|------|------------| +| Provider | string | Y | Provider 名 | 非空 | +| Model | string | Y | 模型名,用于匹配请求中的 `target_model` | 非空 | +| BaseModel | string | Y | 归一化模型名 | 非空 | +| Mode | string | N | 请求模式,例如 `chat` | - | +| Capabilities | []string | N | 能力列表,例如 `["chat", "reasoning"]` | - | +| SupportedParameters | []string | N | 支持的请求参数列表,例如 `["temperature", "max_tokens"]` | - | +| Limits | map[string]integer | N | 限制对象,例如 `context_window` 等 | - | +| Prices | map[string]number | N | 默认价格对象;未命中任何 tier 时使用 | - | +| TierPrices | map[string]map[string]number | N | 分时段价格对象;tier name -> 价格表。**初期 tier name 只支持 `peak`**;tier 内未配置的键 fallback 到 `Prices` | 内部键名须为 `prices` 枚举键名 | + +--- + +## 10. 配置示例 ```json { @@ -345,82 +553,175 @@ cluster_conf.data为集群转发配置文件。 } ] } + } + }, + "ai_cluster_anthropic_example": { + "BackendConf": { + "Protocol": "https", + "TimeoutConnSrv": 2000, + "TimeoutResponseHeader": 50000, + "MaxIdleConnsPerHost": 0, + "RetryLevel": 0 + }, + "CheckConf": { + "Schem": "https", + "Uri": "/healthcheck", + "Host": "example.org", + "StatusCode": 200, + "FailNum": 10, + "CheckInterval": 1000 + }, + "GslbBasic": { + "CrossRetry": 0, + "RetryMax": 2, + "HashConf": { + "HashStrategy": 0, + "HashHeader": "Cookie:UID", + "SessionSticky": false + } }, - "ai_cluster_anthropic_example": { - "BackendConf": { - "Protocol": "https", - "TimeoutConnSrv": 2000, - "TimeoutResponseHeader": 50000, - "MaxIdleConnsPerHost": 0, - "RetryLevel": 0 - }, - "CheckConf": { - "Schem": "https", - "Uri": "/healthcheck", - "Host": "example.org", - "StatusCode": 200, - "FailNum": 10, - "CheckInterval": 1000 - }, - "GslbBasic": { - "CrossRetry": 0, - "RetryMax": 2, - "HashConf": { - "HashStrategy": 0, - "HashHeader": "Cookie:UID", - "SessionSticky": false + "ClusterBasic": { + "TimeoutReadClient": 30000, + "TimeoutWriteClient": 60000, + "TimeoutReadClientAgain": 60000, + "ReqWriteBufferSize": 512, + "ReqFlushInterval": 0, + "ResFlushInterval": -1, + "CancelOnClientClose": false + }, + "AIConf": { + "Type": 0, + "Provider": "my-anthropic", + "ModelProtocols": ["anthropic"], + "Keys": [ + { + "Name": "key-primary", + "Key": "sk-ant-api03-example", + "Weight": 100 } + ], + "KeyPolicy": { + "Strategy": "weighted_random", + "MaxRetries": 3, + "RetryBackoffInitial": 500, + "RetryBackoffMax": 5000 }, - "ClusterBasic": { - "TimeoutReadClient": 30000, - "TimeoutWriteClient": 60000, - "TimeoutReadClientAgain": 60000, - "ReqWriteBufferSize": 512, - "ReqFlushInterval": 0, - "ResFlushInterval": -1, - "CancelOnClientClose": false + "ModelTable": { + "Currency": "RMB", + "Models": [ + { + "Provider": "my-anthropic", + "Model": "claude-3-5-sonnet", + "BaseModel": "claude-3-5-sonnet", + "Mode": "chat", + "Capabilities": ["chat", "tools"], + "SupportedParameters": ["temperature", "max_tokens"], + "Limits": { + "context_window": 200000, + "max_input_tokens": 200000, + "max_output_tokens": 8192 + }, + "Prices": { + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 0.000000375, + "cache_creation_input_token_cost": 0.00000375 + } + } + ] + } + } + }, + "ai_cluster_deepseek_tiered_example": { + "BackendConf": { + "Protocol": "https", + "TimeoutConnSrv": 2000, + "TimeoutResponseHeader": 50000, + "MaxIdleConnsPerHost": 0, + "RetryLevel": 0 + }, + "CheckConf": { + "Schem": "https", + "Uri": "/healthcheck", + "Host": "example.org", + "StatusCode": 200, + "FailNum": 10, + "CheckInterval": 1000 + }, + "GslbBasic": { + "CrossRetry": 0, + "RetryMax": 2, + "HashConf": { + "HashStrategy": 0, + "HashHeader": "Cookie:UID", + "SessionSticky": false + } + }, + "ClusterBasic": { + "TimeoutReadClient": 30000, + "TimeoutWriteClient": 60000, + "TimeoutReadClientAgain": 60000, + "ReqWriteBufferSize": 512, + "ReqFlushInterval": 0, + "ResFlushInterval": -1, + "CancelOnClientClose": false + }, + "AIConf": { + "Type": 0, + "Provider": "deepseek", + "ModelProtocols": ["openai"], + "Keys": [ + { + "Name": "key-primary", + "Key": "sk-example-api-key", + "Weight": 100 + } + ], + "KeyPolicy": { + "Strategy": "weighted_random", + "MaxRetries": 3, + "RetryBackoffInitial": 500, + "RetryBackoffMax": 5000 }, - "AIConf": { - "Type": 0, - "Provider": "my-anthropic", - "ModelProtocols": ["anthropic"], - "Keys": [ + "ModelTable": { + "Currency": "RMB", + "TimeZone": "Asia/Shanghai", + "Tiers": [ { - "Name": "key-primary", - "Key": "sk-ant-api03-example", - "Weight": 100 + "Name": "peak", + "TimeRanges": [ + { "Weekdays": [1, 2, 3, 4, 5], "Start": "09:00", "End": "12:00" }, + { "Weekdays": [1, 2, 3, 4, 5], "Start": "14:00", "End": "18:00" } + ] } ], - "KeyPolicy": { - "Strategy": "weighted_random", - "MaxRetries": 3, - "RetryBackoffInitial": 500, - "RetryBackoffMax": 5000 - }, - "ModelTable": { - "Currency": "RMB", - "Models": [ - { - "Provider": "my-anthropic", - "Model": "claude-3-5-sonnet", - "BaseModel": "claude-3-5-sonnet", - "Mode": "chat", - "Capabilities": ["chat", "tools"], - "SupportedParameters": ["temperature", "max_tokens"], - "Limits": { - "context_window": 200000, - "max_input_tokens": 200000, - "max_output_tokens": 8192 - }, - "Prices": { - "input_cost_per_token": 0.000003, - "output_cost_per_token": 0.000015, - "cache_read_input_token_cost": 0.000000375, - "cache_creation_input_token_cost": 0.00000375 + "Models": [ + { + "Provider": "deepseek", + "Model": "deepseek-v4-pro", + "BaseModel": "deepseek-v4-pro", + "Mode": "chat", + "Capabilities": ["chat", "reasoning", "tools", "prompt_caching"], + "SupportedParameters": ["temperature", "max_tokens"], + "Limits": { + "context_window": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 8192 + }, + "Prices": { + "input_cost_per_token": 0.0000045, + "output_cost_per_token": 0.0000135, + "cache_read_input_token_cost": 0.00000015 + }, + "TierPrices": { + "peak": { + "input_cost_per_token": 0.000009, + "output_cost_per_token": 0.000027, + "cache_read_input_token_cost": 0.0000003 } } - ] - } + } + ] } } } @@ -428,14 +729,16 @@ cluster_conf.data为集群转发配置文件。 } ``` -## 注解 +--- + +## 11. 注解 -### 1. StatusCodeRange +### 11.1 StatusCodeRange -- 响应状态码范围。如果配置了StatusCode,则会忽略此验证条件 +- 响应状态码范围。如果配置了 `StatusCode`,则会忽略此验证条件。 - 合法的配置项举例: - 1. `"3xx"`, `"4xx"`, `"5xx"` 其中之一 - 2. 特定的HTTP返回码,与StatusCode功能一致 - 3. `"|"` 符号连接的上述 (1)或 (2) 例如: + 1. `"3xx"`、`"4xx"`、`"5xx"` 其中之一。 + 2. 特定的 HTTP 返回码,与 `StatusCode` 功能一致。 + 3. `"|"` 符号连接的上述 (1) 或 (2),例如: - `"503|4xx"` - `"501|409|30x"` diff --git a/docs/zh_cn/modifications/2026-08-25-tiered-pricing-support/design-changes.md b/docs/zh_cn/modifications/2026-08-25-tiered-pricing-support/design-changes.md new file mode 100644 index 000000000..1592040d4 --- /dev/null +++ b/docs/zh_cn/modifications/2026-08-25-tiered-pricing-support/design-changes.md @@ -0,0 +1,515 @@ +# BFE 支持分时段/分工作日计费(Tiered Pricing) + +## 1. 背景 + +AI 网关接入的 DeepSeek 等模型已引入峰谷定价: + +- **高峰时段**:北京时间周一至周五 `09:00-12:00`、`14:00-18:00`。 +- **空闲时段**:其余时间。 +- 空闲价格为高峰价格的一半。 + +当前 BFE 的 RMB 配额成本计算只支持固定的 `input_cost_per_token` / `output_cost_per_token`,无法根据请求发生时刻选择不同单价。同时 `ai-gateway-api` 已完成 provider 与 cluster 的概念分离,provider 级别的时段模板(`time_zone`、`tiers`)通过独立接口维护,模型级别的分时段价格(`tier_prices`)保留在 `/model-prices` 中;BFE 接收到的 `AIConf.ModelTable` 由控制面拼接后下发。 + +本变更在不破坏现有 RMB / total_token 配额逻辑的前提下,扩展 BFE 对分时段计费的端到端支持。 + +--- + +## 2. 需求示例 + +以 DeepSeek-V4-Pro 为例(每百万 tokens,单位:元): + +| 计费场景 | 高峰时段 | 空闲时段 | +|---------|---------|---------| +| 输入(缓存命中) | 0.30 | 0.15 | +| 输入(缓存未命中) | 9.00 | 4.50 | +| 输出 | 27.00 | 13.50 | + +对应每 token 单价(元 / token): + +- 高峰输入(缓存未命中):`9.0e-6` +- 空闲输入(缓存未命中):`4.5e-6` +- 高峰缓存命中输入:`3.0e-7` +- 空闲缓存命中输入:`1.5e-7` + +配置形态(由 `ai-gateway-api` 拼接后下发给 BFE): + +```json +{ + "AIConf": { + "Provider": "deepseek", + "ModelTable": { + "Currency": "RMB", + "TimeZone": "Asia/Shanghai", + "Tiers": [ + { + "Name": "peak", + "TimeRanges": [ + { "Weekdays": [1, 2, 3, 4, 5], "Start": "09:00", "End": "12:00" }, + { "Weekdays": [1, 2, 3, 4, 5], "Start": "14:00", "End": "18:00" } + ] + } + ], + "Models": [ + { + "Provider": "deepseek", + "Model": "deepseek-v4-pro", + "Mode": "chat", + "Prices": { + "input_cost_per_token": 4.5e-06, + "output_cost_per_token": 1.35e-05, + "cache_read_input_token_cost": 1.5e-07 + }, + "TierPrices": { + "peak": { + "input_cost_per_token": 9.0e-06, + "output_cost_per_token": 2.7e-05, + "cache_read_input_token_cost": 3.0e-07 + } + } + } + ] + } + } +} +``` + +--- + +## 3. 当前现状 + +| 层级 | 当前能力 | 不足 | +|---|---|---| +| 数据结构 | `bfe_basic.TokenUsage` 已含 `CacheReadTokens` / `CacheWriteTokens` 等缓存字段 | 缺少分时段计费相关字段(本次复用现有缓存字段) | +| 价格配置 | `ModelPrice.Prices` 仅识别固定价格字段 | 无法按请求时刻选择不同价格;缺少 `TierPrices` | +| 时段配置 | `ModelTable` 无 `TimeZone`、`Tiers` 概念 | 无法定义高峰/空闲时段 | +| Usage 解析 | `UpdateCtxByUsage` 已解析缓存命中 token | 不感知请求发生时刻,无法按时段选择价格 | +| 费用计算 | `calcCostUnits` 已支持缓存拆分计费 | 无法支持分时段计费 | + +--- + +## 4. 变更目标 + +1. 在 `ModelTable` 中支持 `TimeZone` 与 `Tiers` 时段定义。 +2. 在 `ModelPrice` 中支持 `TierPrices` 分时段价格表。 +3. 复用现有 `TokenUsage.CacheReadTokens` 字段,支持缓存命中价格按 tier 生效。 +4. 改造 `calcCostUnits`,实现按请求时刻匹配 tier 并取价。 +5. 未配置 `Tiers` / `TierPrices` 时退化到原有固定价格逻辑,保持向后兼容。 +6. **初期约束**:tier name 只支持 `peak`,后续按需扩展。 + +--- + +## 5. 变更总览 + +| 模块 | 主要改动 | +|---|---| +| `bfe_config/bfe_cluster_conf/cluster_conf` | 新增 `TimeRange`、`PriceTier` 结构;扩展 `ModelTable`、`ModelPrice`;`ModelTableCheck` 增加时段校验与 tier 价格定点转换 | +| `bfe_basic` | 复用现有 `TokenUsage.CacheReadTokens` | +| `mod_ai_token_auth` | `calcCostUnits` 按活跃 tier 与缓存命中价格计费 | +| 测试 | 补充时段匹配、缓存计费、固定价格退化等单元测试 | + +--- + +## 6. 详细设计 + +### 6.1 扩展 `ModelTable` / `ModelPrice` + +**文件:** `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` + +新增数据结构: + +```go +type TimeRange struct { + Weekdays []int // 0=周日, 1=周一 ... 6=周六;为空表示每天 + Start string // "HH:MM" + End string // "HH:MM",必须 > Start;跨午夜请拆成两段 +} + +type PriceTier struct { + Name string // 初期只支持 "peak" + TimeRanges []TimeRange // 命中任意一个即属于该 Tier +} + +type ModelPrice struct { + Provider string + Model string + BaseModel string + Mode string + Capabilities []string + SupportedParameters []string + Limits map[string]interface{} + Prices map[string]float64 // 默认价格 + TierPrices map[string]map[string]float64 // tier name -> 价格表 + Metadata map[string]interface{} + + // 运行时字段:配置加载阶段预计算定点整数 + pricesInt map[string]int64 + tierPricesInt map[string]map[string]int64 +} + +type ModelTable struct { + Currency string + TimeZone string // 默认 "Asia/Shanghai" + Tiers []PriceTier // 时段定义 + Models []ModelPrice + + priceIndex map[string]map[string]*ModelPrice + tierIndex map[string]*PriceTier + tz *time.Location +} +``` + +`ModelTableCheck` 增强: + +1. 解析 `TimeZone`,默认 `Asia/Shanghai`。 +2. 校验 `Tiers`:名字非空、**初期 `name` 只支持 `peak`**、时间格式正确、`Weekdays` 合法、同一 Tier 内时间不重叠。 +3. 将默认 `Prices` 和每个 tier 的价格表分别转成定点整数;`TierPrices` 中的 tier name 不强制要求在 `Tiers` 中已定义(保持与 ai-gateway-api 的松耦合),但**初期 `TierPrices` 的键名只支持 `peak`**。 + +示例校验逻辑: + +```go +func ModelTableCheck(table *ModelTable) error { + if table.Currency != quota.UnitRMB { + return fmt.Errorf("currency must be %s", quota.UnitRMB) + } + if table.TimeZone == "" { + table.TimeZone = "Asia/Shanghai" + } + loc, err := time.LoadLocation(table.TimeZone) + if err != nil { + return fmt.Errorf("invalid TimeZone %s: %v", table.TimeZone, err) + } + table.tz = loc + + table.tierIndex = make(map[string]*PriceTier) + for i := range table.Tiers { + tier := &table.Tiers[i] + if tier.Name == "" { + return errors.New("tier name is empty") + } + if tier.Name != "peak" { + return fmt.Errorf("unsupported tier name %s, only 'peak' is allowed", tier.Name) + } + if err := validateTimeRanges(tier.TimeRanges); err != nil { + return fmt.Errorf("tier %s: %v", tier.Name, err) + } + table.tierIndex[tier.Name] = tier + } + + table.priceIndex = make(map[string]map[string]*ModelPrice) + for i := range table.Models { + price := &table.Models[i] + if price.Model == "" || price.Mode == "" { + return errors.New("model/mode is empty") + } + + price.pricesInt = make(map[string]int64) + for key, val := range price.Prices { + if val < 0 { + return fmt.Errorf("negative price %s for model %s", key, price.Model) + } + price.pricesInt[key] = quota.RmbToFixedPoint(val) + } + + price.tierPricesInt = make(map[string]map[string]int64) + for tierName, tierPriceMap := range price.TierPrices { + if tierName != "peak" { + return fmt.Errorf("unsupported tier name %s in TierPrices for model %s, only 'peak' is allowed", tierName, price.Model) + } + intMap := make(map[string]int64) + for key, val := range tierPriceMap { + if val < 0 { + return fmt.Errorf("negative tier price %s for model %s tier %s", key, price.Model, tierName) + } + intMap[key] = quota.RmbToFixedPoint(val) + } + price.tierPricesInt[tierName] = intMap + } + + if table.priceIndex[price.Model] == nil { + table.priceIndex[price.Model] = make(map[string]*ModelPrice) + } + if table.priceIndex[price.Model][price.Mode] != nil { + return fmt.Errorf("duplicate model %s mode %s", price.Model, price.Mode) + } + table.priceIndex[price.Model][price.Mode] = price + } + return nil +} +``` + +### 6.2 复用现有 `TokenUsage` 缓存字段 + +**文件:** `bfe/bfe_basic/request_ai_basic.go` + +当前 `TokenUsage` 已包含 `CacheReadTokens` / `CacheWriteTokens` 字段,本次变更直接复用,无需再扩展: + +```go +type TokenUsage struct { + PromptTokens int64 // 请求侧 Token 数(含 CacheReadTokens) + CompletionTokens int64 // 响应侧 Token 数 + CacheReadTokens int64 // 命中缓存的输入 Token 数 + CacheWriteTokens int64 // 缓存写入 Token 数 + // ... 其他字段 + UsedQuota int64 // 已用 Token 配额(unit=total_token 时使用) + UsedCost int64 // 已用 RMB 成本,1 单位 = 1e-8 元(unit=RMB 时使用) +} +``` + +> 说明:变更总览中列出的 `bfe_basic` 改动为“复用”而非“新增”。 + +### 6.3 Usage 解析 + +**文件:** `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` + +当前 `UpdateCtxByUsage` 已解析缓存命中 token(`usage.cache_read_tokens` / `usage.cache_read_input_tokens` 等),本次变更无需再改。 + +### 6.4 运行时时段匹配 + +**文件:** `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` + +```go +func (table *ModelTable) ActiveTierName(now time.Time) string { + if table == nil || len(table.Tiers) == 0 { + return "" + } + t := now.In(table.tz) + wd := int(t.Weekday()) + hour, min := t.Hour(), t.Minute() + cur := hour*60 + min + + for i := range table.Tiers { + tier := &table.Tiers[i] + for _, tr := range tier.TimeRanges { + if len(tr.Weekdays) > 0 && !containsInt(tr.Weekdays, wd) { + continue + } + start := parseHHMM(tr.Start) + end := parseHHMM(tr.End) + if start <= cur && cur < end { + return tier.Name + } + } + } + return "" +} + +func (p *ModelPrice) GetPriceInt(tier, key string) int64 { + if tier != "" && p.tierPricesInt != nil { + if tierMap, ok := p.tierPricesInt[tier]; ok { + if v, ok := tierMap[key]; ok { + return v + } + } + } + if p.pricesInt != nil { + return p.pricesInt[key] + } + return 0 +} +``` + +### 6.5 RMB 费用计算改造 + +**文件:** `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` + +函数 `calcCostUnits` 根据当前时间匹配活跃 tier,再取对应价格;同时支持缓存命中 / 未命中分层: + +```go +func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe_basic.ServerDataConfInterface, promptTokens, completionTokens int64) int64 { + aiMeta := req.GetAiBasicInfo() + if aiMeta == nil { + return 0 + } + clusterName := req.Route.ClusterName + targetModel := aiMeta.TargetModel + if clusterName == "" || targetModel == "" { + return 0 + } + if serverConf == nil { + return 0 + } + cluster, err := serverConf.ClusterTableLookup(clusterName) + if err != nil || cluster == nil || cluster.AIConf == nil || cluster.AIConf.ModelTable == nil { + log.Logger.Warn("model table not found for cluster %s", clusterName) + return 0 + } + + entry := cluster_conf.LookupModelPrice(cluster.AIConf.ModelTable, targetModel, "chat") + if entry == nil { + log.Logger.Warn("model price not found for cluster %s model %s", clusterName, targetModel) + return 0 + } + + tierName := cluster.AIConf.ModelTable.ActiveTierName(time.Now()) + + cacheReadTokens := aiMeta.GetTokenUsage().CacheReadTokens + if cacheReadTokens > promptTokens { + cacheReadTokens = promptTokens + } + uncachedTokens := promptTokens - cacheReadTokens + + inputCost := entry.GetPriceInt(tierName, "input_cost_per_token") + outputCost := entry.GetPriceInt(tierName, "output_cost_per_token") + cacheReadCost := entry.GetPriceInt(tierName, "cache_read_input_token_cost") + + if inputCost < 0 || outputCost < 0 || cacheReadCost < 0 { + log.Logger.Warn("invalid model price for cluster %s model %s tier %s", clusterName, targetModel, tierName) + return 0 + } + + var cost int64 + if cacheReadCost > 0 { + cost = uncachedTokens*inputCost + cacheReadTokens*cacheReadCost + completionTokens*outputCost + } else { + cost = promptTokens*inputCost + completionTokens*outputCost + } + return cost +} +``` + +--- + +## 7. 计费规则速查 + +### 7.1 命中 `peak` tier + +``` +cacheRead = min(CacheReadTokens, PromptTokens) +uncached = PromptTokens - cacheRead + +cost = uncached × input_cost_per_token + + cacheRead × cache_read_input_token_cost + + CompletionTokens × output_cost_per_token +``` + +其中所有价格字段均取 `tier_prices.peak`,若 `tier_prices.peak` 中未配置某键,则 fallback 到默认 `prices` 中的对应键。 + +### 7.2 未命中任何 tier(向后兼容 / 空闲时段) + +``` +cost = PromptTokens × input_cost_per_token + + CompletionTokens × output_cost_per_token +``` + +若配置了 `cache_read_input_token_cost`,则同样拆分缓存命中 / 未命中部分。 + +### 7.3 未配置 `Tiers` / `TierPrices`(完全向后兼容) + +行为与现有逻辑完全一致,按固定 `Prices` 计费。 + +--- + +## 8. 边界情况与兼容性 + +| 场景 | 处理建议 | +|---|---| +| `Tiers` 为空 | 无时段匹配,`ActiveTierName` 返回空字符串,使用默认 `Prices` | +| `TierPrices` 为空 | 始终使用默认 `Prices` | +| 命中 tier 但该 tier 未配置某个价格键 | fallback 到默认 `Prices` 中的对应键 | +| `CacheReadTokens > PromptTokens` | 截断为 `PromptTokens`,避免普通 input 为负 | +| `CacheReadTokens` 为负 | 按 0 处理 | +| 未配置 `cache_read_input_token_cost` | 全部按 `input_cost_per_token` 计算 | +| `ModelTable.TimeZone` 为空 | 默认 `Asia/Shanghai` | +| `TierPrices` 中出现非 `peak` 的 tier name | 配置加载阶段拒绝(初期约束) | +| `Tiers` 中出现非 `peak` 的 tier name | 配置加载阶段拒绝(初期约束) | +| ModelMapping / fallback cluster | 按最终 `target_model` + 最终 `cluster` 的 `ModelTable` 计费 | + +--- + +## 9. 测试计划 + +### 9.1 单元测试 + +在 `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go` 中新增: + +1. `TestModelTableCheck_Tiers`:验证有效 / 无效时间格式 / 重叠时间范围 / 非 `peak` tier name 拒绝。 +2. `TestModelTableCheck_TierPrices`:验证 tier 价格定点整数转换 / 非 `peak` tier name 拒绝。 +3. `TestActiveTierName`:验证北京时区周一 10:00 命中 `peak`、周一 13:00 未命中、周六 10:00 未命中、周一 18:00 不命中 `peak`。 + +在 `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go` 中新增: + +4. `TestCalcCostUnits_Peak`:验证高峰时段按 `tier_prices.peak` 计费正确。 +5. `TestCalcCostUnits_OffPeak`:验证非高峰时段 fallback 到默认 `Prices`。 +6. `TestCalcCostUnits_Cache`:验证缓存命中 / 未命中分别按对应价格计算。 +7. `TestCalcCostUnits_CacheAwareTier`:验证缓存命中价格在 `peak` tier 与默认价格下均正确。 + +### 9.2 集成测试 + +新增独立 scenario `bfe/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/`,测试设计文档见同目录 `test-design.md`。 + +主要用例(位于 `sc09_rmb_tiered_pricing_test.go`): + +1. `TestTC01_RMBQuotaDeduction_Peak_NonStreaming`: + - `ModelTable` 配置全天覆盖的 `peak` tier 与 `TierPrices`; + - 非流式请求命中 `peak`,验证 Redis 按 `tier_prices.peak` 扣费。 + +2. `TestTC02_RMBQuotaDeduction_NoTier_Fallback`: + - 相同模型仅配置默认 `Prices`,无 `Tiers`; + - 验证 Redis 按默认价格扣费,保持向后兼容。 + +3. `TestTC03_RMBQuotaDeduction_Peak_Cache_NonStreaming`: + - `peak` tier 下返回含 `cache_read_tokens` 的非流式响应; + - 验证缓存命中 / 未命中 token 分别按 `peak` cache 价与普通价扣费。 + +4. `TestTC04_RMBQuotaDeduction_Peak_Cache_Streaming`: + - 与 TC03 相同,但响应为 SSE 流式; + - 验证流式场景下最终 usage chunk 也能按 tier cache 价扣费。 + +运行方式: + +```bash +go test ./tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/... +``` + +--- + +## 10. 实施步骤建议 + +1. **配置层**:扩展 `ModelTable` / `ModelPrice` / `TimeRange` / `PriceTier`;增强 `ModelTableCheck`。 +2. **计费逻辑**:改造 `calcCostUnits`,实现 tier 匹配与缓存命中价格;同步调用点与单元测试。 +4. **测试与回归**:补充配置加载、时段匹配、成本计算单元测试与 RMB 配额集成测试。 + +--- + +## 11. 影响范围 + +| 模块/文件 | 影响 | +|---|---| +| `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` | 新增时段结构与校验、tier 价格定点转换 | +| `bfe/bfe_basic/request_ai_basic.go` | 复用现有 `TokenUsage.CacheReadTokens` | +| `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` | Usage 解析与费用计算改造 | +| `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go` | 新增时段与 tier 价格单元测试 | +| `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go` | 新增分时段计费与缓存计费单元测试 | +| `bfe/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/sc09_rmb_tiered_pricing_test.go` | 新增分时段计费集成测试用例 | +| `bfe/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/test-design.md` | 集成测试设计文档 | +| `bfe/docs/zh_cn/modifications/2026-08-25-tiered-pricing-support/design-changes.md` | 本设计变更文档 | + +--- + +## 12. 兼容性与风险 + +### 12.1 兼容性 + +- `ModelTable` / `ModelPrice` 新增字段均为可选,不填时行为与现有逻辑完全一致。 +- `TokenUsage.UsedCost`、Lua 扣减逻辑、Redis 定点数存储均不改变。 +- Token 配额(`total_token`)扣减逻辑不变。 +- Redis key 结构、配置格式均不发生改变。 + +### 12.2 风险与缓解 + +| 风险 | 缓解措施 | +|---|---| +| 时区解析失败导致配置加载报错 | `ModelTableCheck` 中校验 `time.LoadLocation`,非法时返回明确错误 | +| `CacheReadTokens` 异常大于 `PromptTokens` | `calcCostUnits` 中校验并截断 | +| 时段规则变更后扣费不一致 | 配置热重载后按新规则执行,旧请求不受影响 | +| 初期约束 `peak` 导致后续扩展时需改校验 | 将 tier name 校验逻辑集中,便于后续放开 | + +--- + +## 13. 参考资料 + +- `ai-gateway-api/design-docs/sys-design/details/provider与cluster概念分离.md` +- `ai-gateway-api/design-docs/api-define/OpenAPI接口定义/model-prices.md` +- `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` +- `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` +- `bfe/bfe_basic/request_ai_basic.go` diff --git a/docs/zh_cn/sys_design/rmb_quota.md b/docs/zh_cn/sys_design/rmb_quota.md index c42b59508..989a4d438 100644 --- a/docs/zh_cn/sys_design/rmb_quota.md +++ b/docs/zh_cn/sys_design/rmb_quota.md @@ -27,7 +27,8 @@ v0.6 引入 **图像生成按次计费**:图像生成模型(如 `flux-2-pro` 4. 新增共享库 `go-lib/quota`,提供 RMB 定点数转换,供 ai-gateway-api 与 BFE 共同引用; 5. Redis Lua 支持 RMB 扣减脚本,当前暂时使用单 Key 定点数方案; 6. 支持按 `cache_read_tokens` / `cache_write_tokens` / `audio_input_tokens` / `audio_output_tokens` 子项拆分计费,并与普通 input/output 价格共存; -7. 支持图像生成模型按 `image_count` 与 `output_cost_per_image` 计费,并按请求路径识别 `mode`。 +7. 支持图像生成模型按 `image_count` 与 `output_cost_per_image` 计费,并按请求路径识别 `mode`; +8. v0.5 引入 **分时段/分工作日计费**:`ModelTable` 支持 `TimeZone` 与 `Tiers` 定义,`ModelPrice` 支持 `TierPrices`,请求发生时按当前时刻匹配 tier,命中则取 tier 价格,未命中 fallback 到默认 `Prices`;**初期 tier name 只支持 `peak`**。 ## 2. 设计原则 @@ -161,6 +162,69 @@ func buildModelTableIndex(table *ModelTable) error { > - conf-agent 不感知 `unit` 类型,也不修改价格格式。 > - `go-lib/quota` 同时被 ai-gateway-api 和 BFE 引用,保证管理面与数据面对 Redis 值的解释完全一致。 +### 4.5 分时段/分工作日计费配置 + +v0.5 在 `ModelTable` 与 `ModelPrice` 中扩展分时段计费字段: + +```go +type TimeRange struct { + Weekdays []int // 0=周日, 1=周一 ... 6=周六;为空表示每天 + Start string // "HH:MM" + End string // "HH:MM",必须 > Start;跨午夜请拆成两段 +} + +type PriceTier struct { + Name string // 初期只支持 "peak" + TimeRanges []TimeRange // 命中任意一个即属于该 Tier +} + +type ModelPrice struct { + Provider string + Model string + BaseModel string + Mode string + Capabilities []string + SupportedParameters []string + Limits map[string]interface{} + Prices map[string]float64 // 默认价格 + TierPrices map[string]map[string]float64 // tier name -> 价格表 + Metadata map[string]interface{} + + // 运行时字段:配置加载阶段预计算定点整数 + pricesInt map[string]int64 + tierPricesInt map[string]map[string]int64 +} + +type ModelTable struct { + Currency string // 仍是 "RMB" + TimeZone string // 默认 "Asia/Shanghai" + Tiers []PriceTier // 时段定义 + Models []ModelPrice + + priceIndex map[string]map[string]*ModelPrice + tierIndex map[string]*PriceTier + tz *time.Location +} +``` + +配置归属:provider/cluster 分离后,时段模板(`time_zone`、`tiers`)由 `/providers` 维护,通过独立接口 `PUT /providers/{provider_name}/pricing-tiers` 设置;模型级分时段价格(`tier_prices`)保留在 `/model-prices`。`ai-gateway-api` 导出 BFE 配置时,把同一 provider 的时段模板与 model-prices 的价格数据拼接成 `AIConf.ModelTable`。 + +校验规则: + +1. `ModelTable.TimeZone` 为空时默认 `"Asia/Shanghai"`,须为合法 IANA 时区名。 +2. `Tiers` 中每个 tier 必须包含非空 `Name` 和至少一个 `TimeRange`。 +3. **初期 `Tiers` 中 tier 的 `Name` 只支持 `"peak"`**。 +4. `TimeRange.Weekdays` 元素必须在 `0-6` 之间;为空表示每天。 +5. `TimeRange.Start` / `End` 格式为 `"HH:MM"`,且 `End` > `Start`;跨午夜需拆成两段。 +6. 同一 tier 内部 `TimeRanges` 不得重叠;不同 tier 之间允许重叠,按列表顺序匹配第一个。 +7. `TierPrices` 中 tier name **初期只支持 `"peak"`**;内部价格键名须为 `Prices` 的合法枚举键;`TierPrices` 与 `Tiers` 不做强制引用校验,未在 `Tiers` 中定义的 tier name 运行时自然无法命中。 + +加载阶段处理: + +- 解析 `TimeZone` 并缓存 `*time.Location`。 +- 构建 `tierIndex[name] -> *PriceTier`,便于运行时 O(1) 查询。 +- 将 `TierPrices` 中每个 tier 的价格表同样通过 `go-lib/quota.RmbToFixedPoint` 转换为定点整数,存入 `tierPricesInt`。 + ## 5. 共享库 `go-lib/quota` 为避免 ai-gateway-api 与 BFE 对 Redis 中 RMB 配额值的解释不一致,定点数转换逻辑统一抽取到 `go-lib/quota`: @@ -610,6 +674,75 @@ func lookupModelPrice(table *cluster_conf.ModelTable, model, mode string) *clust 索引在配置加载阶段构建,运行时按 `(model, mode)` 精确查询,为 O(1)。`mode` 由请求路径推断(如 `/v1/images/generations` → `image_generation`,`/v1/chat/completions` → `chat`),未识别时默认 `chat`。未命中时返回 `nil`,由调用方决定是否按 `0` 成本处理。 +### 7.8 分时段计费运行时匹配 + +`ModelTable` 在运行时根据请求发生时刻(取 BFE 本地时间,按 `TimeZone` 转换)匹配活跃 tier: + +```go +func (table *ModelTable) ActiveTierName(now time.Time) string { + if table == nil || len(table.Tiers) == 0 { + return "" + } + t := now.In(table.tz) + wd := int(t.Weekday()) + hour, min := t.Hour(), t.Minute() + cur := hour*60 + min + + for i := range table.Tiers { + tier := &table.Tiers[i] + for _, tr := range tier.TimeRanges { + if len(tr.Weekdays) > 0 && !containsInt(tr.Weekdays, wd) { + continue + } + start := parseHHMM(tr.Start) + end := parseHHMM(tr.End) + if start <= cur && cur < end { + return tier.Name + } + } + } + return "" +} + +func (p *ModelPrice) GetPriceInt(tier, key string) int64 { + if tier != "" && p.tierPricesInt != nil { + if tierMap, ok := p.tierPricesInt[tier]; ok { + if v, ok := tierMap[key]; ok { + return v + } + } + } + if p.pricesInt != nil { + return p.pricesInt[key] + } + return 0 +} +``` + +`calcCostUnits` 在计算成本前先调用 `ActiveTierName`,再按 tier 取价。以 `chat` 模式为例,`calcChatCost` 在读取 `inputCost`、`outputCost`、`cacheReadCost` 等定点整数价格时,均通过 `GetPriceInt(tierName, key)` 获取:命中 `peak` tier 且 `TierPrices.peak` 中配置了该键,则使用 tier 价格;否则 fallback 到默认 `Prices`。 + +```go +func (m *ModuleAITokenAuth) calcCostUnits(req *bfe_basic.Request, serverConf bfe_basic.ServerDataConfInterface, usage *bfe_basic.TokenUsage) int64 { + // ... 查找 entry ... + + tierName := cluster.AIConf.ModelTable.ActiveTierName(time.Now()) + + inputCost := entry.GetPriceInt(tierName, cluster_conf.PriceInputCostPerTokenInt) + outputCost := entry.GetPriceInt(tierName, cluster_conf.PriceOutputCostPerTokenInt) + cacheReadCost := entry.GetPriceInt(tierName, cluster_conf.PriceCacheReadInputTokenCostInt) + // ... 其他子项价格同样按 tierName 获取 ... + + // 后续 cache/audio 拆分与 7.6 节一致 +} +``` + +计费规则: + +- 命中 `peak` tier:所有价格优先取 `TierPrices.peak`;若某键未配置,fallback 到默认 `Prices`。 +- 未命中任何 tier:全部使用默认 `Prices`,行为与 v0.4 完全一致。 +- `Tiers` 为空或 `TierPrices` 为空:直接退化到固定价格逻辑。 +- 区间采用左闭右开 `[Start, End)`,例如 `18:00` 不命中 `09:00-18:00`。 + ## 8. Redis Lua 脚本改造 当前 Token 配额 Lua: @@ -768,6 +901,64 @@ return {yuan, frac} > 上例中 `input_cost_per_token = 0.000001` 元/Token,换算为固定点整数即 `100`(= 0.000001 * 1e8),表示 **0.1 元 / 百万 Token**;`cache_read_input_token_cost` 等子项价格同样通过 `quota.RmbToFixedPoint` 转换为定点整数;`output_cost_per_image = 0.03` 元/张,换算为定点整数 `3000000`。 +### 9.2 分时段计费配置示例(DeepSeek) + +以 DeepSeek-V4-Pro 为例,北京时间周一至周五 `09:00-12:00`、`14:00-18:00` 为高峰时段,其余时间(含工作日非高峰及周末)为空闲时段;空闲价格为高峰价格的一半: + +```json +{ + "AIConf": { + "Type": 0, + "Provider": "deepseek", + "ModelTable": { + "Currency": "RMB", + "TimeZone": "Asia/Shanghai", + "Tiers": [ + { + "Name": "peak", + "TimeRanges": [ + { "Weekdays": [1, 2, 3, 4, 5], "Start": "09:00", "End": "12:00" }, + { "Weekdays": [1, 2, 3, 4, 5], "Start": "14:00", "End": "18:00" } + ] + } + ], + "Models": [ + { + "Provider": "deepseek", + "Model": "deepseek-v4-pro", + "BaseModel": "deepseek-v4-pro", + "Mode": "chat", + "Capabilities": ["chat", "reasoning", "tools", "prompt_caching"], + "SupportedParameters": ["temperature", "max_tokens"], + "Limits": { + "context_window": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 8192 + }, + "Prices": { + "input_cost_per_token": 4.5e-06, + "output_cost_per_token": 1.35e-05, + "cache_read_input_token_cost": 1.5e-07 + }, + "TierPrices": { + "peak": { + "input_cost_per_token": 9.0e-06, + "output_cost_per_token": 2.7e-05, + "cache_read_input_token_cost": 3.0e-07 + } + } + } + ] + } + } +} +``` + +> 说明: +> - 本示例只定义 `peak` tier;未命中高峰时段时,`calcChatCost` 自动使用默认 `Prices`(即空闲价格)。 +> - `TierPrices.peak` 中未配置的键将 fallback 到 `Prices` 中的对应键。 +> - 若后续需要把周末等时段单独定价,可新增 tier 并通过 `Weekdays` 指定,无需修改计费逻辑。 + ## 10. 测试建议 1. **单元测试** @@ -778,6 +969,8 @@ return {yuan, frac} - 覆盖子项用量大于总量时的 clamp 行为; - 覆盖未配置子项价格时回退到普通 input/output 价格的行为; - 覆盖 `image_generation` 模式按 `image_count × output_cost_per_image` 计费,以及未配置 `output_cost_per_image` 时按 0 成本处理。 + - `ActiveTierName`:验证北京时区周一 10:00 命中 `peak`、周一 13:00 未命中、周六 10:00 未命中、周一 18:00 不命中(左闭右开)。 + - `GetPriceInt`:验证命中 `peak` 时取 `TierPrices.peak`、未命中时 fallback 到 `Prices`、tier 中未配置某键时 fallback 到默认价格。 2. **Lua 脚本测试** - 单 Key 定点数方案:验证扣减、余额归零、负数不溢出。 @@ -790,6 +983,8 @@ return {yuan, frac} - 测试流式(SSE)场景:请求体带 `stream: true`,后端返回 SSE 并在最后一个 chunk 中携带 `usage`,验证 RMB 配额仍能正确扣减。 - 测试 cache/audio 子项计费场景:后端返回 `usage.cache_read_tokens`、`usage.cache_write_tokens`、`usage.audio_input_tokens`、`usage.audio_output_tokens`,验证成本按各子项价格拆分计算; - 测试图像生成按次计费场景:请求 `/v1/images/generations`,`ModelTable` 配置 `output_cost_per_image`,后端返回 `usage.image_count`,验证 RMB 配额按 `image_count × output_cost_per_image` 扣减,且 `total_token` 配额按 `image_count` 扣减。 + - 测试分时段计费场景:`ModelTable` 配置 `Tiers` 与 `TierPrices`;分别在北京时间高峰时段(如周一 10:00)与非高峰时段(如周一 13:00 或周六 10:00)发起请求,验证 Redis 扣减金额分别按 `TierPrices.peak` 与默认 `Prices` 计算。 + - 测试分时段 + cache 命中组合场景:高峰时段且后端返回 `usage.cache_read_tokens`,验证缓存命中部分按 `TierPrices.peak.cache_read_input_token_cost` 计费,未命中部分按 `TierPrices.peak.input_cost_per_token` 计费。 ## 11. 兼容性与注意事项 @@ -803,3 +998,4 @@ return {yuan, frac} 5. **流式响应计费**:RMB 成本在请求结束阶段计算,依赖 `mod_body_process`(或其他响应处理模块)在流式传输过程中填充 `PromptTokens` / `CompletionTokens` / `CacheReadTokens` / `CacheWriteTokens` / `AudioInputTokens` / `AudioOutputTokens` / `ImageCount`。生产环境若启用流式计费,需确保 `mod_body_process` 已加载。 6. **模块顺序建议**:`mod_ai_token_auth` 的 `HandleReadResponse` 不再负责 RMB 成本计算,因此对模块加载顺序的敏感度降低;但仍建议保持 `mod_ai_token_auth` 在 `mod_body_process` 之前注册,以便非流式场景下 token 用量解析逻辑保持一致。 7. **旧字段清理**:`AIConf.Key` 已移除,统一使用 `AIConf.Keys`。 +8. **分时段计费向后兼容**:`ModelTable.Tiers` / `ModelPrice.TierPrices` 均为可选字段;未配置时行为与固定价格完全一致。命中 tier 但该 tier 未配置某个价格键时,自动 fallback 到默认 `Prices`。 diff --git a/tests/integration/common/mock_backend.go b/tests/integration/common/mock_backend.go index 89a8ca10c..556624df6 100644 --- a/tests/integration/common/mock_backend.go +++ b/tests/integration/common/mock_backend.go @@ -57,7 +57,7 @@ type MockBackend struct { // request to determine the response status and body. ResponseFunc func(r *http.Request, count int) (int, string) // ResponseHeaders, if non-nil, is written to the response before the status code. - ResponseHeaders map[string]string + ResponseHeaders map[string]string hits int mu sync.Mutex models []string diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/sc09_rmb_tiered_pricing_test.go b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/sc09_rmb_tiered_pricing_test.go new file mode 100644 index 000000000..9fda4410f --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/sc09_rmb_tiered_pricing_test.go @@ -0,0 +1,402 @@ +// Copyright (c) 2026 The BFE Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sc09 + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/bfenetworks/bfe/bfe_config/bfe_cluster_conf/cluster_conf" + "github.com/bfenetworks/bfe/tests/integration/common" +) + +const ( + apiHost = "tier.example.org" + apiHostNoTier = "notier.example.org" + apiPath = "/v1/chat/completions" + apiKey = "ak_user_a" + apiKeyId = "user_a_key_id" + + // Reuse the well-known cluster/sub names from SC03 so that no common + // helper changes are required. + clusterTierPeak = "cluster_rmb" + clusterNoTier = "cluster_no_table" + + planRMB = "plan_rmb" + redisKeyRMB = "quota:plan_rmb" +) + +var defaultBody = []byte(`{"model":"deepseek-chat"}`) +var streamBody = []byte(`{"model":"deepseek-chat","stream":true}`) +var cacheBody = []byte(`{"model":"deepseek-chat"}`) +var cacheStreamBody = []byte(`{"model":"deepseek-chat","stream":true}`) + +var usageResponse = `{"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150}}` + +var streamUsageResponse = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n" + + "data: {\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":50,\"total_tokens\":150}}\n\n" + +var cacheUsageResponse = `{"usage":{"prompt_tokens":8000,"completion_tokens":1500,"total_tokens":9500,"cache_read_tokens":5000,"cache_write_tokens":1000}}` + +var cacheStreamUsageResponse = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n" + + "data: {\"usage\":{\"prompt_tokens\":8000,\"completion_tokens\":1500,\"total_tokens\":9500,\"cache_read_tokens\":5000,\"cache_write_tokens\":1000}}\n\n" + +// testEnv holds all resources for a single SC09 integration test. +type testEnv struct { + t *testing.T + processEnv *common.ProcessEnv + backends map[string]*common.MockBackend + redis *common.RedisServer + bfePort int + stopBFE func() +} + +func newTestEnv(t *testing.T, aiConfs map[string]*cluster_conf.AIConf) *testEnv { + e := &testEnv{ + t: t, + backends: make(map[string]*common.MockBackend), + } + + e.backends[clusterTierPeak] = common.NewMockBackend(clusterTierPeak, http.StatusOK, usageResponse) + e.backends[clusterNoTier] = common.NewMockBackend(clusterNoTier, http.StatusOK, usageResponse) + + e.redis = common.NewRedisServer(t) + + e.processEnv = common.NewProcessEnv(t) + e.processEnv.Build() + + confDir := filepath.Join(e.processEnv.WorkDir(), "conf") + logDir := filepath.Join(e.processEnv.WorkDir(), "log") + + tokenRule := &common.TokenRuleData{ + Version: "1.0", + QuotaPlans: map[string][]common.QuotaPlan{ + "ai_product": { + { + Id: planRMB, + Unlimited: false, + PassNoQuota: false, + RedisKey: redisKeyRMB, + ExpiredTime: -1, + Quota: 10000000000, + Unit: "RMB", + }, + }, + }, + Tokens: map[string]map[string]common.TokenFile{ + "ai_product": { + apiKey: { + Key: apiKey, + KeyId: apiKeyId, + Enabled: true, + ExpiredTime: -1, + UnlimitedQuota: false, + QuotaPlans: []string{planRMB}, + }, + }, + }, + Config: map[string][]common.TokenRule{ + "ai_product": { + { + Cond: "default_t()", + Action: common.ActionFile{Cmd: "CHECK_TOKEN"}, + }, + }, + }, + } + + builder := &common.BFEConfigBuilder{ + TemplateDir: "testdata", + TargetConfDir: confDir, + Backends: e.backends, + AIConfs: aiConfs, + RedisAddr: e.redis.Addr(), + TokenRuleData: tokenRule, + } + if err := builder.Build(); err != nil { + t.Fatalf("build bfe config failed: %v", err) + } + + e.bfePort, _, e.stopBFE = e.processEnv.StartBFE(confDir, logDir) + return e +} + +func (e *testEnv) Close() { + if e.stopBFE != nil { + e.stopBFE() + } + for _, b := range e.backends { + b.Close() + } + if e.redis != nil { + e.redis.Close() + } +} + +func (e *testEnv) logBFEException() { + data, err := os.ReadFile(filepath.Join(e.processEnv.WorkDir(), "log", "exception.log")) + if err == nil && len(data) > 0 { + e.t.Logf("bfe exception log:\n%s", string(data)) + } +} + +func (e *testEnv) logBFEAccess() { + data, err := os.ReadFile(filepath.Join(e.processEnv.WorkDir(), "log", "access.log")) + if err == nil && len(data) > 0 { + e.t.Logf("bfe access log:\n%s", string(data)) + } +} + +func (e *testEnv) sendRequest(host string, body []byte) (*http.Response, string, error) { + url := fmt.Sprintf("http://127.0.0.1:%d%s", e.bfePort, apiPath) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, "", err + } + req.Host = host + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + return resp, string(respBody), nil +} + +// tierPeakAIConf configures a ModelTable with an all-day "peak" tier and tier prices. +func tierPeakAIConf() *cluster_conf.AIConf { + return &cluster_conf.AIConf{ + Type: 0, + Provider: "mock-provider", + Keys: []cluster_conf.AIKey{ + {Name: "key-primary", Key: "sk-primary", Weight: 100}, + }, + KeyPolicy: &cluster_conf.AIKeyPolicy{ + Strategy: "weighted_random", + MaxRetries: 0, + RetryBackoffInitial: 50, + RetryBackoffMax: 200, + }, + ModelTable: &cluster_conf.ModelTable{ + Currency: "RMB", + TimeZone: "Asia/Shanghai", + Tiers: []cluster_conf.PriceTier{ + { + Name: "peak", + TimeRanges: []cluster_conf.TimeRange{ + { + // Cover all weekdays and all minutes of the day so the test + // is deterministic regardless of when it runs. + Weekdays: []int{0, 1, 2, 3, 4, 5, 6}, + Start: "00:00", + End: "23:59", + }, + }, + }, + }, + Models: []cluster_conf.ModelPrice{ + { + Provider: "mock-provider", + Model: "deepseek-chat", + BaseModel: "deepseek-chat", + Mode: "chat", + Capabilities: []string{"chat"}, + SupportedParameters: []string{"temperature", "max_tokens"}, + Limits: map[string]interface{}{ + "context_window": 128000, + }, + Prices: map[string]float64{ + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": 0.0000005, + }, + TierPrices: map[string]map[string]float64{ + "peak": { + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": 0.000001, + }, + }, + }, + }, + }, + } +} + +// noTierAIConf configures the same default prices but without any Tiers / TierPrices. +func noTierAIConf() *cluster_conf.AIConf { + conf := tierPeakAIConf() + conf.ModelTable.Tiers = nil + for i := range conf.ModelTable.Models { + conf.ModelTable.Models[i].TierPrices = nil + } + return conf +} + +// TestTC01 verifies RMB quota deduction using peak tier prices for a non-streaming request. +func TestTC01_RMBQuotaDeduction_Peak_NonStreaming(t *testing.T) { + aiConfs := map[string]*cluster_conf.AIConf{ + clusterTierPeak: tierPeakAIConf(), + } + e := newTestEnv(t, aiConfs) + defer e.Close() + + e.redis.SetQuota(redisKeyRMB, 10000000000) + + resp, body, err := e.sendRequest(apiHost, defaultBody) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if e.backends[clusterTierPeak].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterTierPeak, e.backends[clusterTierPeak].Hits()) + } + + // wait for async redis deduction + time.Sleep(500 * time.Millisecond) + remaining := e.redis.GetQuota(redisKeyRMB) + // peak: input=0.000002 -> 200, output=0.000004 -> 400 + want := int64(10000000000 - (100*200 + 50*400)) + if remaining != want { + e.logBFEException() + e.logBFEAccess() + t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) + } +} + +// TestTC02 verifies that the absence of Tiers falls back to default prices. +func TestTC02_RMBQuotaDeduction_NoTier_Fallback(t *testing.T) { + aiConfs := map[string]*cluster_conf.AIConf{ + clusterNoTier: noTierAIConf(), + } + e := newTestEnv(t, aiConfs) + defer e.Close() + + e.redis.SetQuota(redisKeyRMB, 10000000000) + + resp, body, err := e.sendRequest(apiHostNoTier, defaultBody) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if e.backends[clusterNoTier].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterNoTier, e.backends[clusterNoTier].Hits()) + } + + time.Sleep(500 * time.Millisecond) + remaining := e.redis.GetQuota(redisKeyRMB) + // default: input=0.000001 -> 100, output=0.000002 -> 200 + want := int64(10000000000 - (100*100 + 50*200)) + if remaining != want { + e.logBFEException() + e.logBFEAccess() + t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) + } +} + +// TestTC03 verifies peak tier cache-aware pricing for a non-streaming request. +func TestTC03_RMBQuotaDeduction_Peak_Cache_NonStreaming(t *testing.T) { + aiConfs := map[string]*cluster_conf.AIConf{ + clusterTierPeak: tierPeakAIConf(), + } + e := newTestEnv(t, aiConfs) + defer e.Close() + + e.redis.SetQuota(redisKeyRMB, 10000000000) + e.backends[clusterTierPeak].Body = cacheUsageResponse + + resp, body, err := e.sendRequest(apiHost, cacheBody) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if e.backends[clusterTierPeak].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterTierPeak, e.backends[clusterTierPeak].Hits()) + } + + time.Sleep(500 * time.Millisecond) + remaining := e.redis.GetQuota(redisKeyRMB) + // normal_input = 8000 - 5000 = 3000 + // peak: input=200, cache_read=100, output=400 + // cost = 3000*200 + 5000*100 + 1500*400 = 1,700,000 + want := int64(10000000000 - 1700000) + if remaining != want { + e.logBFEException() + e.logBFEAccess() + t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) + } +} + +// TestTC04 verifies peak tier cache-aware pricing for an SSE streaming request. +func TestTC04_RMBQuotaDeduction_Peak_Cache_Streaming(t *testing.T) { + aiConfs := map[string]*cluster_conf.AIConf{ + clusterTierPeak: tierPeakAIConf(), + } + e := newTestEnv(t, aiConfs) + defer e.Close() + + e.redis.SetQuota(redisKeyRMB, 10000000000) + e.backends[clusterTierPeak].ResponseHeaders = map[string]string{"Content-Type": "text/event-stream"} + e.backends[clusterTierPeak].Body = cacheStreamUsageResponse + + resp, body, err := e.sendRequest(apiHost, cacheStreamBody) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if e.backends[clusterTierPeak].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterTierPeak, e.backends[clusterTierPeak].Hits()) + } + + time.Sleep(500 * time.Millisecond) + remaining := e.redis.GetQuota(redisKeyRMB) + want := int64(10000000000 - 1700000) + if remaining != want { + e.logBFEException() + e.logBFEAccess() + t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) + } +} diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/bfe.conf b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/bfe.conf new file mode 100644 index 000000000..83af726b1 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/bfe.conf @@ -0,0 +1,38 @@ +[server] +httpPort = 18080 +httpsPort = 18443 +monitorPort = 18081 +httpAddr = "127.0.0.1" +httpsAddr = "127.0.0.1" +monitorAddr = "127.0.0.1" +MonitorEnabled = true +maxCpus = 1 + +TlsHandshakeTimeout = 30 +ClientReadTimeout = 5 +ClientWriteTimeout = 5 +KeepAliveEnabled = true +GracefulShutdownTimeout = 10 + +EnableAiGateway = true + +accessibleBodySize = 4194304 + +# max total bytes of all active bytes_body buffers (0 means unlimited) +totalBodyBufferSize = 0 + +Modules = mod_ai_route +Modules = mod_ai_token_auth +Modules = mod_body_process + +hostRuleConf = server_data_conf/host_rule.data +routeRuleConf = server_data_conf/route_rule.data +vipRuleConf = server_data_conf/vip_rule.data + +clusterTableConf = cluster_conf/cluster_table.data +gslbConf = cluster_conf/gslb.data +clusterConf = cluster_conf/cluster_conf.data +NameConf = + +maxHeaderUriBytes = 8096 +maxHeaderBytes = 8096 diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/cluster_conf/gslb.data b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/cluster_conf/gslb.data new file mode 100644 index 000000000..8d6192b91 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/cluster_conf/gslb.data @@ -0,0 +1,14 @@ +{ + "clusters": { + "cluster_rmb": { + "GSLB_BLACKHOLE": 0, + "sub_rmb": 100 + }, + "cluster_no_table": { + "GSLB_BLACKHOLE": 0, + "sub_notable": 100 + } + }, + "hostname": "gslb-test", + "ts": "20260720150000" +} diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_ai_route/ai_route.data b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_ai_route/ai_route.data new file mode 100644 index 000000000..e6f156540 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_ai_route/ai_route.data @@ -0,0 +1,40 @@ +{ + "Version": "20260720150000", + "route_rules": { + "apikey_ak_user_a": { + "type": "apikey", + "owner": "ak_user_a", + "rules": [ + { + "name": "user_a-tier", + "Cond": "req_host_in(\"tier.example.org\")", + "targets": [ + { + "ClusterName": "cluster_rmb", + "Model": "", + "Weight": 100 + } + ], + "fallbacks": [] + }, + { + "name": "user_a-notier", + "Cond": "req_host_in(\"notier.example.org\")", + "targets": [ + { + "ClusterName": "cluster_no_table", + "Model": "", + "Weight": 100 + } + ], + "fallbacks": [] + } + ] + } + }, + "ApikeyRouteTableBindings": { + "ak_user_a": [ + "apikey_ak_user_a" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_ai_route/mod_ai_route.conf b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_ai_route/mod_ai_route.conf new file mode 100644 index 000000000..f250013bb --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_ai_route/mod_ai_route.conf @@ -0,0 +1,5 @@ +[basic] +RouteRulePath = mod_ai_route/ai_route.data + +[log] +OpenDebug = true diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_ai_token_auth/mod_ai_token_auth.conf b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_ai_token_auth/mod_ai_token_auth.conf new file mode 100644 index 000000000..966b473d0 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_ai_token_auth/mod_ai_token_auth.conf @@ -0,0 +1,13 @@ +[basic] +ProductRulePath = mod_ai_token_auth/token_rule.data + +[redis] +Bns = 127.0.0.1:6379 +ConnectTimeout = 1000 +ReadTimeout = 1000 +WriteTimeout = 1000 +MaxIdle = 10 +MaxActive = 20 + +[log] +OpenDebug = true diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_body_process/body_process.data b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_body_process/body_process.data new file mode 100644 index 000000000..2a4746d1b --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_body_process/body_process.data @@ -0,0 +1,10 @@ +{ + "Version": "1.0", + "Config": { + "ai_product": [ + { + "Cond": "default_t()" + } + ] + } +} diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_body_process/mod_body_process.conf b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_body_process/mod_body_process.conf new file mode 100644 index 000000000..07fa25732 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/mod_body_process/mod_body_process.conf @@ -0,0 +1,5 @@ +[basic] +ProductRulePath = mod_body_process/body_process.data + +[log] +OpenDebug = true diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/server_data_conf/host_rule.data b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/server_data_conf/host_rule.data new file mode 100644 index 000000000..9552d577e --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/server_data_conf/host_rule.data @@ -0,0 +1,14 @@ +{ + "Version": "20260720150000", + "Hosts": { + "ai_product": [ + "tier.example.org", + "notier.example.org" + ] + }, + "HostTags": { + "ai_product": [ + "ai_product" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/server_data_conf/route_rule.data b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/server_data_conf/route_rule.data new file mode 100644 index 000000000..de638bb02 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/server_data_conf/route_rule.data @@ -0,0 +1,12 @@ +{ + "Version": "20260720150000", + "BasicRule": { + "ai_product": [ + { + "Hostname": ["*"], + "Path": ["*"], + "ClusterName": "cluster_rmb" + } + ] + } +} diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/server_data_conf/vip_rule.data b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/server_data_conf/vip_rule.data new file mode 100644 index 000000000..6fe22f03a --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/server_data_conf/vip_rule.data @@ -0,0 +1,8 @@ +{ + "Version": "20260720150000", + "Vips": { + "ai_vip": [ + "127.0.0.1" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/bfe_i_ca.crt b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/bfe_i_ca.crt new file mode 100644 index 000000000..f1e78f73c --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/bfe_i_ca.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDwDCCAqigAwIBAgIBCzANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzExMDMx +NDAxNDZaFw0zNzA3MTIxNDAxNDZaMIGQMQswCQYDVQQGEwJjbjEQMA4GA1UECAwH +YmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsMC3lpbmdmZWkt +ZGV2MRgwFgYDVQQDDA95aW5nZmVpLWRldi1pY2ExKTAnBgkqhkiG9w0BCQEWGmxp +YW5nY2h1YW5AeWYtbmV0d29ya3MuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA6eFuWgoknixrRO9NCX4jAKyLtcAOWVJqVN2yX7CxjZjLyPurjvTZ +W73NYUbrPAN4AB5gY6UAPzuiEOSopVVIZ0OschK0cJldu9vZ0mZObBOsovuFcLQq +dgNSJ5slJSk7tCgD2EnCB3GYPG4D+uIKYd0c49wzTWWv4bjDwpgnf0LQbFpy7GhN +7D59zFH4qgOK/IQ5vaTMGyvIvtWR5/1Gvc9MLpGopTgi0DiNLed4UwDYrod5kysl +q3UcB5puONHQISOVoD3uRxo7wdsmVsHUfW7YfAWkhi6ec8mx9fy8IyE6f7GlXnuV +ysNccwqyEotL5bOXPJCqwUCL1v+iKTMPWwIDAQABo1AwTjAdBgNVHQ4EFgQUCjvC +vDVr8QXh9/hMI5xBgNvoSFgwHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/oqHdW +1D0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAhIggK/6JK4N7+HZW +VoTemwRpilugZZyKrcVAHbiiwUfXVQVuI64vc+yHWMSFRD1mykHkKBFzxEoDabMl +ASBtUJNt4b4zEL9V7k295vmAOp2IdLUQxlgeKWqABm4DGX96tGR9nKQTcn1ZeAxx +NyQFV1aj+dnNcF1iFFNF6t0bRrOEZ/aRSiu1bWp3Dj1JYTjXbyyplh3Ktb8lv4lt +EmvCXjo/l4TgQC9233kcTkXcq1swppzkkXfhB0NVuf9DE3C2xAWX0b2FiIEqnAhl +Bx0Cn3RrX7PZ6qrdRL6oBKvGJV6DP8BlF4LXiuD1VN/waQFJha57KQ78ZYYIXxQg +WMjWgA== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/bfe_r_ca.crt b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/bfe_r_ca.crt new file mode 100644 index 000000000..2a6db1e49 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/bfe_r_ca.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDkzCCAnugAwIBAgIBCjANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzExMDMx +MzQ5NTVaFw0zNzA3MTIxMzQ5NTVaMGQxCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEUMBIGA1UECwwLeWluZ2ZlaS1k +ZXYxFzAVBgNVBAMMDnlpbmdmZWktZGV2LWNhMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAvNA3HrsMjBcXrMIIhGVWsurIA1F9jxKeA7dh06H00Vt4inVV +SUvNFrTqgPRhLkAhGRMPxrjVRgJ5bbFqqXIuPIpUFBhUsWXIDH+oVXQl9jsxAXaG +gZ0lTO/uYR9qyrS1rj9nyNPwRf59Al/VlsQL71cNQ/T/agJ4PfvPfULTPLOsqclJ +hj0IgXmDj464dqcdG3ZdfXpfhNF6ab+8YjpwafTRmY+LoV8qjUwsYeJMcW4N8pxJ +8F2ktZj9J6uWepNGj+87ZeXg9XquzC62ASIFzPjoE1WN//Q518EqizxhuLGBDpK3 +6sEYUK4kHYUL4gZKFPlKTPXIl0ZsJIM5PsBMKwIDAQABo1AwTjAdBgNVHQ4EFgQU +DZloT8VhVbysD819oG/oqHdW1D0wHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/o +qHdW1D0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEALgB+IcN3UD4T ++4g8jaOyOiSUpUVdYqW+3go5DppqnvlGNq99N2cXKqssVPa/T9TikEcBEicFa8zU +bwlx6TEte+MkWfWdQxFR1EuI1FgKr3ps6ZBRr7MPpnYmI7K9aK372K9n7WrQhbmP +s7ult8bWB1/t6o3R7B9ChNkWT+7DPD4+FvB1GMJSGPno7cdnvDkevBOuC2DnQl3M ++ADFAge1Lo8wKBy6gYkNFd2BfarHGvRC5Qmmrme+RIpWZnvux1+lfXIInnfSTJRM +uAo/ePkoNsM3qQll6uEdhDxOMx8Pq94bCkM3DtI3ObuxWXjKCgUm/n9yetUvPj4U +iYhRUqHpUg== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_bfe_dev.crt b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_bfe_dev.crt new file mode 100644 index 000000000..2164fb602 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_bfe_dev.crt @@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 18 (0x12) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=yingfei-dev-ca + Validity + Not Before: Jan 31 02:55:32 2024 GMT + Not After : Jan 28 02:55:32 2034 GMT + Subject: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=bfe-dev/emailAddress=dev@example.org + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:b2:7f:c1:0c:cd:49:43:d9:99:78:15:4c:5a:52: + a9:a7:bf:d5:eb:92:71:43:e1:37:e5:29:1b:68:f4: + 6f:4c:ea:fa:a4:8a:7c:29:01:2a:fa:7c:81:5b:c8: + eb:a1:40:94:b7:2b:82:e2:00:08:36:84:f0:b7:d2: + 5a:1e:56:97:aa:36:ff:4d:07:49:2d:fe:25:3a:f9: + e9:f1:ad:4e:6e:21:97:a9:f9:a2:a5:ac:82:23:0a: + d2:e0:97:cd:2f:14:b1:f0:8a:a8:e6:c5:97:45:94: + f8:8e:3f:96:66:5b:0b:8c:7c:07:61:18:44:92:f7: + 23:5a:c2:4b:88:58:59:5d:ca:5c:0d:6e:dd:ff:18: + 59:65:df:95:99:e3:3a:36:48:1f:3f:3a:e6:ce:85: + f3:0b:04:5e:92:ed:6f:8e:74:92:e4:37:46:da:5f: + 17:62:9c:82:40:06:fb:29:f8:55:f2:ba:23:75:ca: + 64:c0:45:03:12:bd:f5:17:15:7e:47:d5:bd:30:f2: + 99:ca:6b:e3:07:b0:ae:44:89:1e:10:26:ea:75:df: + 6f:07:b6:47:76:54:47:4f:6c:f6:68:fe:a8:cf:22: + 20:73:e8:19:55:8a:fe:f5:78:e8:51:88:52:80:1f: + 79:d4:c5:ae:8f:d2:2b:f6:41:01:42:01:cf:98:c2: + c9:25 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + EE:E8:68:42:DF:B1:F0:EF:6F:47:51:BD:D4:94:60:1F:05:85:A1:03 + X509v3 Authority Key Identifier: + keyid:0D:99:68:4F:C5:61:55:BC:AC:0F:CD:7D:A0:6F:E8:A8:77:56:D4:3D + + X509v3 Extended Key Usage: + TLS Web Client Authentication + Signature Algorithm: sha256WithRSAEncryption + a9:a6:26:8e:42:61:15:22:ee:fc:b5:e1:e4:6b:dd:ac:f5:15: + 11:39:10:9a:ca:6f:85:fd:cb:90:1c:b2:4f:fe:29:de:b0:e7: + 73:e2:f7:5e:63:8c:7f:c1:7e:75:2e:c9:9d:e9:c2:45:75:f3: + 27:ba:82:94:de:7f:6c:87:0c:5c:71:af:0f:14:00:68:35:f7: + 5a:4a:ff:f5:ef:35:dd:50:72:76:f0:6f:b6:7b:42:33:07:b4: + 24:44:0a:fd:9d:61:9e:44:e8:88:0f:02:76:c6:90:3f:9d:1b: + d8:3b:64:25:2a:a3:39:78:38:bd:20:89:4a:9c:bd:68:38:18: + 4c:cb:20:3a:9b:5b:5f:58:52:86:73:de:85:fe:d6:a1:c6:a7: + 86:b0:96:4b:fa:28:04:ad:5d:85:e8:a1:fc:ca:0f:3c:be:5c: + 90:7e:3e:84:ae:67:ee:9a:72:71:3c:b2:80:45:82:fc:7e:58: + 74:99:42:c5:c3:8a:4a:eb:e1:8b:d5:84:ce:25:aa:a1:75:79: + 94:66:ae:ee:df:30:15:0b:b5:c5:b1:2c:d5:0a:54:78:b6:2e: + 67:29:81:41:f6:16:49:31:96:e7:41:e1:99:6b:27:57:bb:7d: + 76:eb:e4:d5:59:aa:a2:5c:bd:1c:18:2a:fa:9d:28:1a:0b:b6: + bf:7d:58:1a +-----BEGIN CERTIFICATE----- +MIID7jCCAtagAwIBAgIBEjANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yNDAxMzEw +MjU1MzJaFw0zNDAxMjgwMjU1MzJaMH0xCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEUMBIGA1UECwwLeWluZ2ZlaS1k +ZXYxEDAOBgNVBAMMB2JmZS1kZXYxHjAcBgkqhkiG9w0BCQEWD2RldkBleGFtcGxl +Lm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ/wQzNSUPZmXgV +TFpSqae/1euScUPhN+UpG2j0b0zq+qSKfCkBKvp8gVvI66FAlLcrguIACDaE8LfS +Wh5Wl6o2/00HSS3+JTr56fGtTm4hl6n5oqWsgiMK0uCXzS8UsfCKqObFl0WU+I4/ +lmZbC4x8B2EYRJL3I1rCS4hYWV3KXA1u3f8YWWXflZnjOjZIHz865s6F8wsEXpLt +b450kuQ3RtpfF2KcgkAG+yn4VfK6I3XKZMBFAxK99RcVfkfVvTDymcpr4wewrkSJ +HhAm6nXfbwe2R3ZUR09s9mj+qM8iIHPoGVWK/vV46FGIUoAfedTFro/SK/ZBAUIB +z5jCySUCAwEAAaOBkTCBjjAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVu +U1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU7uhoQt+x8O9vR1G9 +1JRgHwWFoQMwHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/oqHdW1D0wEwYDVR0l +BAwwCgYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADggEBAKmmJo5CYRUi7vy14eRr +3az1FRE5EJrKb4X9y5Acsk/+Kd6w53Pi915jjH/BfnUuyZ3pwkV18ye6gpTef2yH +DFxxrw8UAGg191pK//XvNd1Qcnbwb7Z7QjMHtCRECv2dYZ5E6IgPAnbGkD+dG9g7 +ZCUqozl4OL0giUqcvWg4GEzLIDqbW19YUoZz3oX+1qHGp4awlkv6KAStXYXoofzK +Dzy+XJB+PoSuZ+6acnE8soBFgvx+WHSZQsXDikrr4YvVhM4lqqF1eZRmru7fMBUL +tcWxLNUKVHi2LmcpgUH2FkkxludB4ZlrJ1e7fXbr5NVZqqJcvRwYKvqdKBoLtr99 +WBo= +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem new file mode 100644 index 000000000..764aea3ad --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAsn/BDM1JQ9mZeBVMWlKpp7/V65JxQ+E35SkbaPRvTOr6pIp8 +KQEq+nyBW8jroUCUtyuC4gAINoTwt9JaHlaXqjb/TQdJLf4lOvnp8a1ObiGXqfmi +payCIwrS4JfNLxSx8Iqo5sWXRZT4jj+WZlsLjHwHYRhEkvcjWsJLiFhZXcpcDW7d +/xhZZd+VmeM6NkgfPzrmzoXzCwReku1vjnSS5DdG2l8XYpyCQAb7KfhV8rojdcpk +wEUDEr31FxV+R9W9MPKZymvjB7CuRIkeECbqdd9vB7ZHdlRHT2z2aP6ozyIgc+gZ +VYr+9XjoUYhSgB951MWuj9Ir9kEBQgHPmMLJJQIDAQABAoIBAFAKHzupVb/58+o3 +yqv5wx94Uuk2GlnwxIqaezL94GaiO0/K1U/huS7m426P0rDU75qPBTpn/0bLJ9GV +nllaRNnLnYEh0juwaWtfovp+1ttlbseGK9uUVip2cQbKqvQAmKWe14vbcDCAU1Ad +zUgKbUxKVVjBdAZekVjiJNJ3o2L9WPhf/uQo7A1XAJh2DajlTbgvrDM73W+47QuU +X4OHU0FMio6bxupu3OWl1bMrnKhuC4qczZWf2nOpcVQa89rtopuP4ENLJuWkbeGk +YQpNilEclnAa/Noumt/j/6GKC1EEHFsH2CNRRIazcZrsFhkSKc4pn1Y/WI3vj8kZ ++RYnJsUCgYEA6gr0x8suwRTAmVpoPk4XyP1x+eInG7onV5RjgsUtgruYd9naxRHg +2p8PHcv32pDs51Fa+4RldyMd/jec1SscRF5/+VOP9qeoRnaDqUu+uASgEO3OBxbP +JcWovyxRHIQxbYCQtqIr9bdzXw55MBLZou/sBUVTAkrIyPVyjWqZlV8CgYEAwz7L +YyYN615TsrzZKURxMjj94Nmob/NldSLRXaR3Ax7/ABtEOA685cwQxq7ONdkJTMIA +uR8u2GHZSzGiWnehuF6Zp7Xs71a57eFbs3ueZvvEba4Dff7hl7Y4tTlwrKndKjvP +J/5a2Ol8siQcRWAXHOdzggEHMSZ/sB4hWswly/sCgYEAhLRBpyemEwTZUBrbELjm +86gBgFajJi2fMSGKaxOygnYsNYjpauSAQnX99D87Aks6iM6wb/zaK3tV/lc6LgSL +uph6p7yh3JGj8JAyh0PTmDPHLtIoCAz+18QDsqJGO40ZGaXUaDn8Aw9J85QZUxDd +Jm4zvalZL+uHfarukRDolLECgYBUiupS4nWAh3XCnZeDEQna72avaFBROZmjIRJ7 +c+28wj009JmTlH4jGzvgbG0KUBKA1Div8Fq+g5AtyS498jNqvDvYrSQNdwZHhR/K +Fis++KHTxFfqxOU2Zkcj4d1yRpNn6EIJVVBNQL0n/g7n03XupCIWFw/gLoV343QZ +9vAe5QKBgA8ml59z1w3eUooc0yGfLhihXqCmM3IU006bFbODA30fBU4QKrHO8+Yx +Xbz9bi/1QLagLG6FzYQAkOjBlEBt5XLayYvwSb8xvWsm5A3vzTAbMFDOsDPEmRoH +dWtQccJcygOuK+PZtoZnNoJciNO5c9dZWD3xtIiURmX/kVtWrf6N +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_san_example.org.crt b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_san_example.org.crt new file mode 100644 index 000000000..78f3892b4 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_san_example.org.crt @@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 15 (0xf) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=yingfei-dev-ca + Validity + Not Before: Dec 7 09:57:42 2023 GMT + Not After : Dec 4 09:57:42 2033 GMT + Subject: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei, CN=dev-test + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:d6:47:b0:17:69:91:4c:d0:66:c9:25:2e:38:f2: + 84:27:e2:7c:38:cc:04:b9:0c:8e:3d:cc:ef:4b:c5: + 35:2b:c1:82:d8:41:fc:25:c8:24:f2:e0:25:aa:f9: + 76:3c:e2:b2:50:fb:2d:ec:4e:16:d4:c1:da:1e:e3: + 51:9f:9d:c9:72:f9:cb:cc:14:9c:c1:82:c9:76:1f: + 98:dd:0e:b9:8a:20:d6:ab:f2:a6:f9:2f:23:81:f3: + af:e4:47:0c:55:95:94:de:ed:f6:a5:24:ee:32:e7: + 6b:79:e1:42:6f:a4:07:b2:95:1d:f5:a9:8e:60:42: + 70:42:bd:e2:30:18:68:74:52:32:98:a9:81:da:d8: + c6:6f:5e:1d:ce:79:b6:f3:ec:4f:ed:7d:22:57:d2: + 14:d0:fb:f2:50:d4:80:3b:89:ed:77:fd:45:6c:e6: + 52:6b:0a:52:71:ac:59:c8:d5:25:f5:40:03:fb:51: + b0:11:a7:00:79:d9:d8:4f:00:43:96:68:44:29:41: + dc:d2:cc:91:c8:61:95:41:4d:0e:66:5a:b5:15:67: + 3e:8a:6f:29:df:1c:8a:6f:ee:9e:97:9c:9e:69:71: + d3:34:52:75:e9:ea:e7:51:77:23:98:46:ca:47:a2: + d3:d3:97:03:41:4b:e3:33:11:72:2d:af:bf:2b:3e: + b3:51 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + 5A:27:32:9D:E7:36:24:A3:C1:DC:2F:95:80:C5:CF:0C:85:E8:E6:AF + X509v3 Authority Key Identifier: + keyid:0D:99:68:4F:C5:61:55:BC:AC:0F:CD:7D:A0:6F:E8:A8:77:56:D4:3D + + X509v3 Subject Alternative Name: + DNS:example.org, DNS:www.example.org, DNS:example.com, DNS:*.example.com, IP Address:127.0.0.1, IP Address:192.168.0.100 + Signature Algorithm: sha256WithRSAEncryption + 1e:e8:e8:8a:ad:a8:0e:fc:c9:82:00:a1:ab:30:3c:a5:b9:dc: + d6:fb:86:ad:30:52:7f:61:be:90:a6:b8:56:bb:f1:0b:e6:39: + 38:65:09:6b:da:83:f7:65:ff:c4:21:de:b4:9e:8b:bd:1e:1c: + d1:d5:94:b8:18:79:f2:d0:06:51:39:67:13:40:3b:73:5b:cb: + ea:de:c1:19:76:f8:7b:0f:15:51:61:49:fb:98:f7:ea:4f:fc: + c2:fb:a7:f4:3c:48:64:14:79:b5:78:5b:20:10:b5:7a:2d:4c: + 04:51:60:ec:20:10:19:26:5f:e2:fd:32:59:67:e9:3f:48:8d: + f5:52:12:01:81:2c:c0:e5:72:cd:7d:0a:eb:7a:05:df:a0:77: + b9:ba:9a:7d:d1:4b:6a:44:e4:2d:98:af:bd:77:2b:f5:ef:26: + 4b:75:b3:97:d0:3a:bc:07:21:ef:71:92:30:fe:a2:79:e5:56: + d7:7e:c2:f3:57:ab:d7:de:fc:97:ed:20:0c:9a:cb:c5:5d:00: + 3b:61:29:e8:00:d4:39:e0:f2:4e:a4:03:c2:12:52:ff:e7:78: + f9:f7:c0:12:dc:36:a4:05:a2:f0:6b:47:e2:21:3d:a2:e1:a1: + 91:c7:ac:8f:b8:ae:58:65:e0:2b:57:80:eb:77:2d:48:ef:e6: + fb:b9:e1:20 +-----BEGIN CERTIFICATE----- +MIIEBzCCAu+gAwIBAgIBDzANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzEyMDcw +OTU3NDJaFw0zMzEyMDQwOTU3NDJaMFoxCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEQMA4GA1UECwwHeWluZ2ZlaTER +MA8GA1UEAwwIZGV2LXRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQDWR7AXaZFM0GbJJS448oQn4nw4zAS5DI49zO9LxTUrwYLYQfwlyCTy4CWq+XY8 +4rJQ+y3sThbUwdoe41Gfncly+cvMFJzBgsl2H5jdDrmKINar8qb5LyOB86/kRwxV +lZTe7falJO4y52t54UJvpAeylR31qY5gQnBCveIwGGh0UjKYqYHa2MZvXh3Oebbz +7E/tfSJX0hTQ+/JQ1IA7ie13/UVs5lJrClJxrFnI1SX1QAP7UbARpwB52dhPAEOW +aEQpQdzSzJHIYZVBTQ5mWrUVZz6KbynfHIpv7p6XnJ5pcdM0UnXp6udRdyOYRspH +otPTlwNBS+MzEXItr78rPrNRAgMBAAGjgc0wgcowCQYDVR0TBAIwADAsBglghkgB +hvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYDVR0OBBYE +FFonMp3nNiSjwdwvlYDFzwyF6OavMB8GA1UdIwQYMBaAFA2ZaE/FYVW8rA/NfaBv +6Kh3VtQ9ME8GA1UdEQRIMEaCC2V4YW1wbGUub3Jngg93d3cuZXhhbXBsZS5vcmeC +C2V4YW1wbGUuY29tgg0qLmV4YW1wbGUuY29thwR/AAABhwTAqABkMA0GCSqGSIb3 +DQEBCwUAA4IBAQAe6OiKragO/MmCAKGrMDyludzW+4atMFJ/Yb6QprhWu/EL5jk4 +ZQlr2oP3Zf/EId60nou9HhzR1ZS4GHny0AZROWcTQDtzW8vq3sEZdvh7DxVRYUn7 +mPfqT/zC+6f0PEhkFHm1eFsgELV6LUwEUWDsIBAZJl/i/TJZZ+k/SI31UhIBgSzA +5XLNfQrregXfoHe5upp90UtqROQtmK+9dyv17yZLdbOX0Dq8ByHvcZIw/qJ55VbX +fsLzV6vX3vyX7SAMmsvFXQA7YSnoANQ54PJOpAPCElL/53j598AS3DakBaLwa0fi +IT2i4aGRx6yPuK5YZeArV4Drdy1I7+b7ueEg +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem new file mode 100644 index 000000000..393a79825 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA1kewF2mRTNBmySUuOPKEJ+J8OMwEuQyOPczvS8U1K8GC2EH8 +Jcgk8uAlqvl2POKyUPst7E4W1MHaHuNRn53JcvnLzBScwYLJdh+Y3Q65iiDWq/Km ++S8jgfOv5EcMVZWU3u32pSTuMudreeFCb6QHspUd9amOYEJwQr3iMBhodFIymKmB +2tjGb14dznm28+xP7X0iV9IU0PvyUNSAO4ntd/1FbOZSawpScaxZyNUl9UAD+1Gw +EacAednYTwBDlmhEKUHc0syRyGGVQU0OZlq1FWc+im8p3xyKb+6el5yeaXHTNFJ1 +6ernUXcjmEbKR6LT05cDQUvjMxFyLa+/Kz6zUQIDAQABAoIBAC4sYGuLGf49Ygix +9FXdHFEj4rSyccoWRIhYoq/nHOAC4NkMzvKtQBj95+ABxVK1XstIdMrYwN6zrva8 +8Re9/mzCGwIs5uJj9ll30Y7A34Y+MUP4E7baS4JzKlG8ZZIDm4K2MFHBtXpOl8A5 +pAE+jVIUA9Kt6LohVuNq21SVzdxSfNYC/+SLqSftkWa/ZsqdkiHM5Hl+fVedh516 +IaLNW5hSthGh5n8dHY5h/AKPjfoq77aYp5/CUtJTC9mYdZu1j/W/pBVTRfOnwLQd +SQ1Xmr7f6q9Vmz+HnajIbFg9hQ54blvtUJ7DnugWxfUcoxf7ue79fnYjOIUOkRWw +8Iid/mECgYEA+5s0p0j+gkNZN5QtVNStoT04+1DqA1O381gczeaiz6Njt/MT0y5W +OpCsILQ70CpEjWAV+f6PDJiesDMxdGV+v2TCqK8ml8GahEczBLnHoAkPWCVf2XOX +oNj/CkZ2kmWufHFR+kcQbeDt1vFFcYUa61hKDFyNjinMW79Qy+PQID0CgYEA2gWd +7thE05sqU7/1MntmVRONKoAgnJmHcfSpWwLyh6E4YX3iKDSgI/9RnAMF39KUUY/O +XFWyIwAM9soeXknVsV/SmCaPeaEDiLHqz98aUEfvdLYMnuR883GgoXc4JrLsLw4z +oSi9lbAZFn0ekJ5L+rSFrY4rz9QZgYZxsLjUXKUCgYAbkaUSU2g3w8Np2J2i9u7T +hQ7SUsphdPHqAxSc5xGd6MxLYqIgeKpQHnwN1VHcfFUonIer7d2kxrBUpDdeBqT9 +ub+ulgqHhFo29ko70VNzUKrSwL2g6Q6LPFutt4zUe7nDvvL5loHRWF0XOTafurL5 +aKIsepO0KRZQU0U6IgszDQKBgFs6ZHaP2mTtFY4L0a75Ab3xu20gRgUhHRLq/H6P +wipMpMnuodaPBr9pU53Dig65D8T9Nq1eUnbgy4vs0T5FCPz6iqWN5RVQ8aieQhIP +WfRj1Wfx0WAfXcWEM2G9ACr5TWj3OVVjNclP8X9+hW6gPky+gv03c0+4gZ+4QRRg +ksPdAoGBAKXVv8L5Qt8rmi+QpF/6DVSeMB6lEevf319UEtyZPiYSBn1JJ9H+3Ddc +TMriXgZrw+PoXNJTAguDeGgzmtye1vraP0cjt7aG6sQ4YtMTnnuja880vK8z4i3Q +HJAi5fI8NvlW92dQBAccgrzFIKpRx+6CHwtCkNrxL2vJmGGzp8BR +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/certs/example.crt b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/certs/example.crt new file mode 100644 index 000000000..931874885 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/certs/example.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEjCCAfqgAwIBAgIJAIAdu56fLE7OMA0GCSqGSIb3DQEBCwUAMBYxFDASBgNV +BAMMC2V4YW1wbGUub3JnMCAXDTIwMDYxMDEwMzM0NFoYDzIxMjAwNTE3MTAzMzQ0 +WjAWMRQwEgYDVQQDDAtleGFtcGxlLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBALv+LV1aWIlcK9rI7IuRS8SCusqBnoyJec/ErKiA2gbfgZ/YS73L +Zud84yp45AIqauzcI5q+hrkmsRZ7CKqDzrG+jHavW7jF+0laetJwRt26AcQcOtQD +2ik2O+Dl1WHAFn4vUAQxb+Xz6WfSaQN0QfM74z06XUDDsr7g7+NYtMzhf98SJSoK +ne/dVKJ3Bc6e6tvhnCRwPtix4ektEodK6WeNHYxwJ6wSZ8cRLzdxgjdD/4OGfFuj +dn8zbOi3SQt5ZqVbcDHUTzp5t0G8EoxnzotHhhzjSAmsypySqZXaxl3oX8aYUkFn +fCdg+WBXo5pOiNfoWh/D5bnIXWGp52yoy+kCAwEAAaNhMF8wHQYDVR0lBBYwFAYI +KwYBBQUHAwIGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFIH+0G3eCswQHbN06kvI80M3 +tNH9MB0GA1UdDgQWBBSxLHQE7gOEyfeSNc5uIO/G/rgjpzANBgkqhkiG9w0BAQsF +AAOCAQEAlGm5RwQ79xmLh3rj+5UViCSgsIuMcuhgIT4zogpo9S4uwXMqinrJhzRk +Oc2tb3y06XTAq1lMH2+58tqndAu8ni/UBz3OSghk2CTnZ1vxxXOd3CtQu4ypMq+k +qW0Umdrkk5TeAODNbrCy4c6vpICkQOljnRFWnDYu3aQ3JvaWZ/nObN7C72Lgpjfb +RfLXGmLsBCEIr028f9hpoeRCXoetUY2CiC2boAHR+cO6Jpvex4Jv5yYDpNKac52n +LLC8Cq5ozhOZNOSV6X9FpEca3rdhVUb0VgoNDCPZDdpO1PDJYCN9fUC8KUs+dsOh +SYGpliBaNsztoiJs1q5SkMQrMwmMmg== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/certs/example.key b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/certs/example.key new file mode 100644 index 000000000..b21c2f08d --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/certs/example.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAu/4tXVpYiVwr2sjsi5FLxIK6yoGejIl5z8SsqIDaBt+Bn9hL +vctm53zjKnjkAipq7Nwjmr6GuSaxFnsIqoPOsb6Mdq9buMX7SVp60nBG3boBxBw6 +1APaKTY74OXVYcAWfi9QBDFv5fPpZ9JpA3RB8zvjPTpdQMOyvuDv41i0zOF/3xIl +Kgqd791UoncFzp7q2+GcJHA+2LHh6S0Sh0rpZ40djHAnrBJnxxEvN3GCN0P/g4Z8 +W6N2fzNs6LdJC3lmpVtwMdRPOnm3QbwSjGfOi0eGHONICazKnJKpldrGXehfxphS +QWd8J2D5YFejmk6I1+haH8PluchdYannbKjL6QIDAQABAoIBAEUBL7WsjAMfihls +1ycD1kPzmIzstz3u2H+jOZ1AbsdHE1WRF3w7RTKDbP8SEN+aolT/GTKb7OfZg/c0 +giHU7/Hed8C47XoNcgei5qKIA/svY6aQlidsoo+uEJykwIZ488itpTlkzCYkOfCa +E2HpMqwNt4OqAMDdFKdr+aIB1Zu+KPBxW23WD9wEWAbe5LA4YnRF9kT4YZ6y9mce +dGaIf39VtBlrGMmvoU0LE9B79nyuebGi0svW6QDarBqaDrnM/N3fXgL1kk/gVfan +/xs6EA4qPxA5G4h+enYrIlZL0CbSj60nYElo+Z5nRdBaRdCF/bpXOLyK/kXWLUM0 +f2HTK+ECgYEA5cVcpJtczxEaxoaEUbppsW1LCrTjJDGTKJ63G7/lwqCxJeCHN185 +nnckHOW2287e19bu9aUmKJgRq5s1rXnT+MnCl/hQnfaMrKOKtzE+t7/zsc9+LuAr +pwJrtZ9Dcnwrk8NOE0fPjW5XpDCSoEOo7JWZmGTVlpabOgNkjocfp+sCgYEA0XPt +ZPt3F0wyzgLYRhgnvp5CV8SzQmulsW+ytnL5eiAcNSXqni3wQHN3PGxLInEyQwBQ +/M8TQpUbqGMmahCK4ZxMAwXMrpF0mVB8jfoYMou1FSYPlUV+CvLjWcTkZB1Nirez +VFXdtfHP0mx4PbYK2qjB03u4pPHAN8kuayIf2nsCgYAbv/FHZAgabfto3Jggcr4P +Ep8MhPolxeL69egxbsSl89hRNcO+2T5ROBxhbRDfjSV2tduYSUDJiEwiCJW8BMmn +814QEopR+ZPVyc6X/1eOw5z/7YpUyPgcrHsrrTdtHTf6GY1VYMfdUeU9zCv5NRKy +uAKb2Bm/nSLUJ9K+L+2PzwKBgQDRQgT3UtTUjehkMitpPFDY/LxDe92simfsMjBW +X+Anx1TnNI6GolbZzYJe98LJElao4fQH38raRqZvQT/rz8MxTDoU+wJXljLryaHn +Jupt9W5hRrli5R7cSXYjBbc43p3N7WJY68CqOoDrNjubS/jkJJ4hcAY1pOHp2jFq +D5nLaQKBgAysU6O5kJ8yKxhbZflb42MqKCFBGrbRnbYx14PAEZOaRhzxehpppQmx +RLbn/z1Uh5Ms28ipxA+vnhyM3FcU5lKboaFyWJeuNslw0FxEcIai6hL6UkDznS4G +aqyzUjpG5Chg0x18xWYCbiGJwjZ9BWhtH+jojm856QHGzeQJWVoo +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/client_ca/example_ca.crt b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/client_ca/example_ca.crt new file mode 100644 index 000000000..b0fa2fa28 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/client_ca/example_ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDATCCAemgAwIBAgIJAMsPuHg4mqnaMA0GCSqGSIb3DQEBCwUAMBYxFDASBgNV +BAMMC2V4YW1wbGUub3JnMCAXDTIwMDYxMDEwMzMwNloYDzIxMjAwNTE3MTAzMzA2 +WjAWMRQwEgYDVQQDDAtleGFtcGxlLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBAL72D2gOnJN9Zvo9KjruwM1EsFe3xZRJ0NvZ5bHd6+5jhlgCAhQ+ +AGb7ufEiYOi2JWHl2Bkq0iVrp+zv0RLdq0oVjX+OG5H2yWbnC7ifbNjir93LX0un +tIqv5CIbExDSBRkufxfV37yjXdrcMqYSbD2Kw3PfAbWs1Dego8fRz8QAp5+LCvW2 +BZZyYi6JzhCAUW1+8OQPyzOhB50eSJiS5xgVA7wkwmYeVUpHqU8sv4VzjM3bmUc7 +1mPLlnRVIqScrqYgQ9Ou21vZebOJ8+ckVL8O3XHhMZlssBbWiBFZZnaNWbzcEI90 +oiW4YAQ5t7gaXuCVvaiNvq2VZknarR6AxcsCAwEAAaNQME4wHQYDVR0OBBYEFIH+ +0G3eCswQHbN06kvI80M3tNH9MB8GA1UdIwQYMBaAFIH+0G3eCswQHbN06kvI80M3 +tNH9MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJtHx8ibLp+CYDR1 +ZVeQ3qg4OeRL0q21+2EgBJq6zOUt/9SxThA80aXJ8CYH9dnCW+fOnpEk1xFWxtXc +FwSLsnqAdwOJaaQWoMzhyqjZV5x5G9+MW5FzGGOdes2md2Z+tAwMoV9TVtxZkbKy +mC2tDJdvgLgt9/YcbUcZPDbyZojdZ+UbATm+Lro9dhTXt91vsAgz5QA9e08rQVkF +pc9+ZQ5zxBsoblQ+ozPOWOdV4zJVx+wQsAnOG2qU0yVQAscGsTo4wnzFrAU54fO7 +Lh4cOrY0P1/o65yiSzwK7f0jwBeT/jEfMOrJ7pPo7doUov0iVj0SZyTM3HFa2Mzj +2zGTk0o= +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/client_ca/example_ca.key b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/client_ca/example_ca.key new file mode 100644 index 000000000..4f7ad13b1 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/client_ca/example_ca.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAvvYPaA6ck31m+j0qOu7AzUSwV7fFlEnQ29nlsd3r7mOGWAIC +FD4AZvu58SJg6LYlYeXYGSrSJWun7O/REt2rShWNf44bkfbJZucLuJ9s2OKv3ctf +S6e0iq/kIhsTENIFGS5/F9XfvKNd2twyphJsPYrDc98BtazUN6Cjx9HPxACnn4sK +9bYFlnJiLonOEIBRbX7w5A/LM6EHnR5ImJLnGBUDvCTCZh5VSkepTyy/hXOMzduZ +RzvWY8uWdFUipJyupiBD067bW9l5s4nz5yRUvw7dceExmWywFtaIEVlmdo1ZvNwQ +j3SiJbhgBDm3uBpe4JW9qI2+rZVmSdqtHoDFywIDAQABAoIBAD60bbqtkZycwQPK +seNIIudEduNW5PocgwiuNE6DoMVWyPZ9MlGTSm6GmjgkIc5IgV30K1GYTgkboLic +xvp675QUH7KS51q2vsubcq3dK9DMHxOlhFVDbHVd7HuGiGwtip8KNZGOGTnIKzmC +tN7zjbdnqWaTA+y0I7tgdGdY7fBd9Rzgaq+OlPq2u33HvWHvlG/7PfpZuXB5YLgd +m04l7LJ7ikhIjycg7j27v/4c6xCiH5jMJKsZ+nfsQ0kEEo9DkhcKInK+wHsMzKsH +Cy3AdlE0IRsbxRAoMumVs2g5u90m3zBPkRrNdZ2Ni7BesnhxbkIqvb4SfpxKyuhK +fADfZgECgYEA7SUIS2gII0TGjXh0h16d+eoLVOz0eVpgF3XXxmgtAPu10dqxVEC2 +j5FSBCgZhqZ3axVotP71c2mT+hF+Mqy4TLMfA/B9jKLXjZlPbg4EcAgI7tALskwz +Bk5BkX0k825bU9P0j+AlpLx6/ztHr2N9/cKZfqQVrO9t+FRusvAHkHsCgYEAziT8 +F30Ch2s6IJngCj5jH164iN2CoFXjqPNVgRj45gLE3zrf1R7u2JTeEhjWNLZ6IWZ3 +G/bT7eYm6x8u7LFlORdnWKsHlftGu0igRyvIGcxoHgjXlsLidBaEP+HlOLUtTumu +MfQJUozLcrOBIV6m9VhPnSDTeCg/tOqy68V2pvECgYAUYgd5e8KfTW0Hgd/6Nq67 +aVt5/DfzKkpyGcXnHtMnb3ssQ3DUfg9y/ZmgE9ZF1Y8UHC34yKVOOzfl2ZUQQ/o/ +VXIIA6a27NQ8Ln4+RmQpQPeLl0Q6GgSUuSs3lxsS9VxSMzilGS4DH9QulejOcW3F +3vEUioP2bkn0e0VcifcMewKBgAG1Pr13FLFIiye//qI3GB0nbMH9i9qGO6entHqo +WU+WkEkFNNuQMQxsV1axC/1N0b87GRuLNQBQmtvx2zKs2Zjaf8m1SQ/OECz3EhTk +4PiNwAMXsamXHcc2dIwO9BY/MgvoVcAmNHmRnxHpONWs8hcwTyCPKBFjy/tUwny/ +mxcRAoGAcNRxLZlyRqmQ6zGf41GK4ZIR9gix0L6Km49S1maGFmcbctOR2GcQN8Eo +f38rkrFBfBfuFSGzghJiXDvKORg9r3V/bzSKcXkprJra6hzn5vn+t7wurjzaJUlK +zUW5dl3SU2bC4MK+X7bwqf9jm9b7FXSt4p1xly8Uh/Mufwi7zOM= +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/server_cert_conf.data b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/server_cert_conf.data new file mode 100644 index 000000000..49f228531 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/server_cert_conf.data @@ -0,0 +1,12 @@ +{ + "Version": "init version", + "Config": { + "Default": "example.org", + "CertConf": { + "example.org": { + "ServerCertFile": "tls_conf/certs/example.crt", + "ServerKeyFile" : "tls_conf/certs/example.key" + } + } + } +} diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/session_ticket_key.data b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/session_ticket_key.data new file mode 100644 index 000000000..b3d2356b0 --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/session_ticket_key.data @@ -0,0 +1,4 @@ +{ + "Version": "init version", + "SessionTicketKey": "08a0d852ef494143af613ef32d3c39314758885f7108e9ab021d55f422a454f7c9cd5a53978f48fa1063eadcdc06878f" +} diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/tls_rule_conf.data b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/tls_rule_conf.data new file mode 100644 index 000000000..66e7b5dbb --- /dev/null +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/testdata/tls_conf/tls_rule_conf.data @@ -0,0 +1,20 @@ +{ + "Version": "12", + "DefaultNextProtos": ["http/1.1"], + "Config": { + "example_product": { + "VipConf": [ + "10.199.4.14" + ], + "SniConf": ["example.org"], + "CertName": "example.org", + "NextProtos": [ + "h2;rate=100;isw=65535;mcs=200;level=0", + "http/1.1" + ], + "Grade": "C", + "ClientAuth": false, + "ClientCAName": "example_ca" + } + } +} diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-01-peak-tier\351\235\236\346\265\201\345\274\217\346\214\211tier\344\273\267\346\211\243\350\264\271.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-01-peak-tier\351\235\236\346\265\201\345\274\217\346\214\211tier\344\273\267\346\211\243\350\264\271.md" new file mode 100644 index 000000000..3eda949ff --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-01-peak-tier\351\235\236\346\265\201\345\274\217\346\214\211tier\344\273\267\346\211\243\350\264\271.md" @@ -0,0 +1,83 @@ +# TC-01 `peak` tier 非流式按 tier 价扣费 + +## 用例编号与名称 + +TC-01 `peak` tier 非流式按 tier 价扣费 + +## 所属场景 + +SC09 RMB 分时段计费 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证当 `AIConf.ModelTable` 配置了 `Tiers` 与 `TierPrices` 时,BFE 在活跃 tier 时段内按 `TierPrices` 扣减 RMB 配额。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动,并预置 `quota:plan_rmb = 10000000000`(100 元)。 +3. mock 后端 `cluster_rmb` 已启动,返回 200 与如下 body: + ```json + { + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150 + } + } + ``` +4. 临时 BFE 配置已生成并加载,`cluster_rmb` 配置 `ModelTable`: + - `Tiers` 中 `peak` 覆盖全周全天; + - `deepseek-chat` 的默认 input/output 价格分别为 `0.000001` / `0.000002`; + - `deepseek-chat` 的 `TierPrices.peak` input/output 价格分别为 `0.000002` / `0.000004`。 +5. `ak_user_a` 绑定 RMB 配额计划 `plan_rmb`。 + +## 配置构造 + +- `cluster_rmb.AIConf.ModelTable.Tiers[0]`: + - `Name`: `peak` + - `TimeRanges[0].Weekdays`: `[0,1,2,3,4,5,6]` + - `TimeRanges[0].Start`: `00:00` + - `TimeRanges[0].End`: `23:59` +- `cluster_rmb.AIConf.ModelTable.Models[0]`: + - `Model`: `deepseek-chat` + - `Mode`: `chat` + - `Prices.input_cost_per_token`: `0.000001` + - `Prices.output_cost_per_token`: `0.000002` + - `TierPrices.peak.input_cost_per_token`: `0.000002` + - `TierPrices.peak.output_cost_per_token`: `0.000004` +- `plan_rmb`: + - `Unit`: `RMB` + - `Quota`: `10000000000` + - `RedisKey`: `quota:plan_rmb` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `tier.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"deepseek-chat"}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_rmb` 收到 1 次命中。 +- Redis 中 `quota:plan_rmb` 的余额变为: + - 扣减金额 = `100 * 200 + 50 * 400 = 40000`(0.0004 元) + - 剩余 = `10000000000 - 40000 = 9999960000` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-02-\346\227\240Tiers\346\214\211\351\273\230\350\256\244\344\273\267\346\240\274\346\211\243\350\264\271.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-02-\346\227\240Tiers\346\214\211\351\273\230\350\256\244\344\273\267\346\240\274\346\211\243\350\264\271.md" new file mode 100644 index 000000000..cbec50b5b --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-02-\346\227\240Tiers\346\214\211\351\273\230\350\256\244\344\273\267\346\240\274\346\211\243\350\264\271.md" @@ -0,0 +1,78 @@ +# TC-02 无 `Tiers` 按默认价格扣费 + +## 用例编号与名称 + +TC-02 无 `Tiers` 按默认价格扣费 + +## 所属场景 + +SC09 RMB 分时段计费 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证当 `AIConf.ModelTable` 未配置 `Tiers` 时,BFE 退化到默认 `Prices` 计费,保持向后兼容。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动,并预置 `quota:plan_rmb = 10000000000`(100 元)。 +3. mock 后端 `cluster_no_table` 已启动,返回 200 与如下 body: + ```json + { + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150 + } + } + ``` +4. 临时 BFE 配置已生成并加载,`cluster_no_table` 配置 `ModelTable`: + - 无 `Tiers`; + - `deepseek-chat` 的默认 input/output 价格分别为 `0.000001` / `0.000002`; + - 无 `TierPrices`。 +5. `ak_user_a` 绑定 RMB 配额计划 `plan_rmb`。 + +## 配置构造 + +- `cluster_no_table.AIConf.ModelTable.Tiers`:空 +- `cluster_no_table.AIConf.ModelTable.Models[0]`: + - `Model`: `deepseek-chat` + - `Mode`: `chat` + - `Prices.input_cost_per_token`: `0.000001` + - `Prices.output_cost_per_token`: `0.000002` + - `TierPrices`:空 +- `plan_rmb`: + - `Unit`: `RMB` + - `Quota`: `10000000000` + - `RedisKey`: `quota:plan_rmb` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `notier.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"deepseek-chat"}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_no_table` 收到 1 次命中。 +- Redis 中 `quota:plan_rmb` 的余额变为: + - 扣减金额 = `100 * 100 + 50 * 200 = 20000`(0.0002 元) + - 剩余 = `10000000000 - 20000 = 9999980000` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-03-peak-tier\347\274\223\345\255\230\345\221\275\344\270\255\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-03-peak-tier\347\274\223\345\255\230\345\221\275\344\270\255\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" new file mode 100644 index 000000000..4523fd6db --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-03-peak-tier\347\274\223\345\255\230\345\221\275\344\270\255\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" @@ -0,0 +1,88 @@ +# TC-03 `peak` tier + 缓存命中非流式计费 + +## 用例编号与名称 + +TC-03 `peak` tier + 缓存命中非流式计费 + +## 所属场景 + +SC09 RMB 分时段计费 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证在 `peak` tier 下,后端返回含 `cache_read_tokens` 的非流式响应时,BFE 按 tier cache 拆分公式扣减 RMB 配额。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动,并预置 `quota:plan_rmb = 10000000000`(100 元)。 +3. mock 后端 `cluster_rmb` 已启动,返回 200 与如下 body: + ```json + { + "usage": { + "prompt_tokens": 8000, + "completion_tokens": 1500, + "total_tokens": 9500, + "cache_read_tokens": 5000, + "cache_write_tokens": 1000 + } + } + ``` +4. 临时 BFE 配置已生成并加载,`cluster_rmb` 配置 `ModelTable`: + - `Tiers` 中 `peak` 覆盖全周全天; + - `deepseek-chat` 的默认 input/output/cache_read 价格分别为 `0.000001` / `0.000002` / `0.0000005`; + - `deepseek-chat` 的 `TierPrices.peak` input/output/cache_read 价格分别为 `0.000002` / `0.000004` / `0.000001`。 +5. `ak_user_a` 绑定 RMB 配额计划 `plan_rmb`。 + +## 配置构造 + +- `cluster_rmb.AIConf.ModelTable.Tiers[0]`: + - `Name`: `peak` + - `TimeRanges[0].Weekdays`: `[0,1,2,3,4,5,6]` + - `TimeRanges[0].Start`: `00:00` + - `TimeRanges[0].End`: `23:59` +- `cluster_rmb.AIConf.ModelTable.Models[0]`: + - `Model`: `deepseek-chat` + - `Mode`: `chat` + - `Prices.input_cost_per_token`: `0.000001` + - `Prices.output_cost_per_token`: `0.000002` + - `Prices.cache_read_input_token_cost`: `0.0000005` + - `TierPrices.peak.input_cost_per_token`: `0.000002` + - `TierPrices.peak.output_cost_per_token`: `0.000004` + - `TierPrices.peak.cache_read_input_token_cost`: `0.000001` +- `plan_rmb`: + - `Unit`: `RMB` + - `Quota`: `10000000000` + - `RedisKey`: `quota:plan_rmb` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `tier.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"deepseek-chat"}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_rmb` 收到 1 次命中。 +- Redis 中 `quota:plan_rmb` 的余额变为: + - `normal_input = 8000 - 5000 = 3000` + - 扣减金额 = `3000 * 200 + 5000 * 100 + 1500 * 400 = 1700000` + - 剩余 = `10000000000 - 1700000 = 9999830000` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-04-peak-tier\347\274\223\345\255\230\345\221\275\344\270\255SSE\346\265\201\345\274\217\350\256\241\350\264\271.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-04-peak-tier\347\274\223\345\255\230\345\221\275\344\270\255SSE\346\265\201\345\274\217\350\256\241\350\264\271.md" new file mode 100644 index 000000000..5f19fd38d --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-04-peak-tier\347\274\223\345\255\230\345\221\275\344\270\255SSE\346\265\201\345\274\217\350\256\241\350\264\271.md" @@ -0,0 +1,86 @@ +# TC-04 `peak` tier + 缓存命中 SSE 流式计费 + +## 用例编号与名称 + +TC-04 `peak` tier + 缓存命中 SSE 流式计费 + +## 所属场景 + +SC09 RMB 分时段计费 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证在 `peak` tier 下,SSE 流式响应的最终 chunk 含 `cache_read_tokens` 时,BFE 按 tier cache 拆分公式扣减 RMB 配额。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动,并预置 `quota:plan_rmb = 10000000000`(100 元)。 +3. mock 后端 `cluster_rmb` 已启动,返回 200,响应头包含 `Content-Type: text/event-stream`,body 为 SSE 格式: + ```text + data: {"choices":[{"delta":{"role":"assistant"}}]} + + data: {"choices":[{"delta":{"content":"hello"}}]} + + data: {"usage":{"prompt_tokens":8000,"completion_tokens":1500,"total_tokens":9500,"cache_read_tokens":5000,"cache_write_tokens":1000}} + + ``` +4. 临时 BFE 配置已生成并加载,`cluster_rmb` 配置 `ModelTable`: + - `Tiers` 中 `peak` 覆盖全周全天; + - `deepseek-chat` 的默认 input/output/cache_read 价格分别为 `0.000001` / `0.000002` / `0.0000005`; + - `deepseek-chat` 的 `TierPrices.peak` input/output/cache_read 价格分别为 `0.000002` / `0.000004` / `0.000001`。 +5. `bfe.conf` 已启用 `mod_body_process`,确保 SSE 流式响应被处理。 +6. `ak_user_a` 绑定 RMB 配额计划 `plan_rmb`。 + +## 配置构造 + +- `cluster_rmb.AIConf.ModelTable.Tiers[0]`: + - `Name`: `peak` + - `TimeRanges[0].Weekdays`: `[0,1,2,3,4,5,6]` + - `TimeRanges[0].Start`: `00:00` + - `TimeRanges[0].End`: `23:59` +- `cluster_rmb.AIConf.ModelTable.Models[0]`: + - `Model`: `deepseek-chat` + - `Mode`: `chat` + - `Prices.input_cost_per_token`: `0.000001` + - `Prices.output_cost_per_token`: `0.000002` + - `Prices.cache_read_input_token_cost`: `0.0000005` + - `TierPrices.peak.input_cost_per_token`: `0.000002` + - `TierPrices.peak.output_cost_per_token`: `0.000004` + - `TierPrices.peak.cache_read_input_token_cost`: `0.000001` +- `plan_rmb`: + - `Unit`: `RMB` + - `Quota`: `10000000000` + - `RedisKey`: `quota:plan_rmb` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `tier.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"deepseek-chat","stream":true}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_rmb` 收到 1 次命中。 +- Redis 中 `quota:plan_rmb` 的余额变为: + - `normal_input = 8000 - 5000 = 3000` + - 扣减金额 = `3000 * 200 + 5000 * 100 + 1500 * 400 = 1700000` + - 剩余 = `10000000000 - 1700000 = 9999830000` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/\345\234\272\346\231\257\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/\345\234\272\346\231\257\350\257\264\346\230\216.md" new file mode 100644 index 000000000..314c62eaa --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/\345\234\272\346\231\257\350\257\264\346\230\216.md" @@ -0,0 +1,209 @@ +# SC09 RMB 分时段计费 + +## 1. 场景背景与目的 + +BFE v0.5 在 `AIConf.ModelTable` 中引入 `Tiers` / `TierPrices` 支持,允许按请求发生时刻匹配不同价格 tier(如 `peak` / `off_peak`),并区分工作日与休息日。 + +本场景验证: + +- 配置 `Tiers` 后,请求在活跃 tier 时段内命中,按 `TierPrices` 扣减 RMB 配额。 +- 未配置 `Tiers` 时退化到默认 `Prices` 计费,保持向后兼容。 +- 在 `peak` tier 下正确区分缓存命中 / 未命中 token,分别使用对应价格。 +- 对非流式和 SSE 流式响应均正确计费。 + +> 说明:为解决 `ActiveTierName` 使用 `time.Now()` 导致的测试时间不确定性,本场景将 `peak` tier 配置为覆盖全周全天(`00:00-23:59`,weekdays `[0,1,2,3,4,5,6]`),保证任意时刻运行测试都会命中 `peak`。 + +## 2. 运行模式 + +- **单组件模式**:仅启动真实 `bfe` 进程与嵌入式 Redis(`github.com/alicebob/miniredis/v2`)。 +- 不涉及 `ai-gateway-api` 与 `conf-agent`。 + +## 3. 涉及的 BFE 配置文件 + +| 文件 | 说明 | +|------|------| +| `bfe.conf` | 启用 `EnableAiGateway`,加载 `mod_ai_route`、`mod_ai_token_auth`、`mod_body_process` | +| `mod_ai_route/mod_ai_route.conf` | 指定 `ai_route.data` 路径 | +| `mod_ai_route/ai_route.data` | 定义 apikey 级路由表与绑定 | +| `mod_ai_token_auth/mod_ai_token_auth.conf` | 指定 `token_rule.data` 路径与 Redis 地址 | +| `mod_ai_token_auth/token_rule.data` | 定义 Token、QuotaPlan 与路由规则 | +| `server_data_conf/host_rule.data` | 声明 `ai_product` 产品线对应的 Host | +| `server_data_conf/route_rule.data` | 默认路由到 fallback cluster | +| `cluster_conf/cluster_conf.data` | 各 cluster 的基础配置,重点在 `AIConf` | +| `cluster_conf/gslb.data` | GSLB 权重配置 | +| `cluster_conf/cluster_table.data` | 运行时根据 mock 后端地址动态生成 | + +## 4. Cluster 与 AIConf 设计 + +### 4.1 测试 cluster + +| cluster | 用途 | +|---------|------| +| `cluster_rmb` | 配置 `Tiers` + `TierPrices`,用于验证 tier 价格命中 | +| `cluster_no_table` | 只配置默认 `Prices`,无 `Tiers`,用于验证 fallback 到默认价格 | + +> 为复用 SC03 已有的 cluster/sub 名称映射,避免改动公共 helper,本场景中的集群仍命名为 `cluster_rmb` 与 `cluster_no_table`,但其语义分别对应“配置了 tier”与“未配置 tier”。 + +### 4.2 `cluster_rmb` 的 `AIConf` + +```json +{ + "AIConf": { + "Type": 0, + "Provider": "mock-provider", + "Keys": [ + { + "Name": "key-primary", + "Key": "sk-primary", + "Weight": 100 + } + ], + "KeyPolicy": { + "Strategy": "weighted_random", + "MaxRetries": 0 + }, + "ModelTable": { + "Currency": "RMB", + "TimeZone": "Asia/Shanghai", + "Tiers": [ + { + "Name": "peak", + "TimeRanges": [ + { + "Weekdays": [0, 1, 2, 3, 4, 5, 6], + "Start": "00:00", + "End": "23:59" + } + ] + } + ], + "Models": [ + { + "Provider": "mock-provider", + "Model": "deepseek-chat", + "BaseModel": "deepseek-chat", + "Mode": "chat", + "Capabilities": ["chat"], + "SupportedParameters": ["temperature", "max_tokens"], + "Limits": { + "context_window": 128000 + }, + "Prices": { + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000002, + "cache_read_input_token_cost": 0.0000005 + }, + "TierPrices": { + "peak": { + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": 0.000001 + } + } + } + ] + } + } +} +``` + +> 说明: +> - 默认 `Prices`: +> - `input_cost_per_token = 0.000001` 元/Token → 定点整数 `100` +> - `output_cost_per_token = 0.000002` 元/Token → 定点整数 `200` +> - `cache_read_input_token_cost = 0.0000005` 元/Token → 定点整数 `50` +> - `TierPrices.peak`: +> - `input_cost_per_token = 0.000002` 元/Token → 定点整数 `200` +> - `output_cost_per_token = 0.000004` 元/Token → 定点整数 `400` +> - `cache_read_input_token_cost = 0.000001` 元/Token → 定点整数 `100` +> - `peak` 价格为默认价格的两倍,便于断言时快速区分。 + +### 4.3 `cluster_no_table` 的 `AIConf` + +与 `cluster_rmb` 结构相同,但 `Tiers` 为空、`TierPrices` 为空,仅保留默认 `Prices`。 + +### 4.4 路由表设计 + +#### apikey_ak_user_a + +- `user_a-tier` + - 条件:`req_host_in("tier.example.org")` + - targets:`cluster_rmb` + - fallbacks:无 +- `user_a-notier` + - 条件:`req_host_in("notier.example.org")` + - targets:`cluster_no_table` + - fallbacks:无 + +#### ApikeyRouteTableBindings + +```json +{ + "ak_user_a": ["apikey_ak_user_a"] +} +``` + +## 5. Token 与 QuotaPlan 设计 + +### 5.1 Token 配置 + +| API Key | 绑定的 QuotaPlan | +|---------|------------------| +| `ak_user_a` | `plan_rmb` | + +### 5.2 QuotaPlan 配置示例 + +```json +{ + "Id": "plan_rmb", + "Unlimited": false, + "PassNoQuota": false, + "RedisKey": "quota:plan_rmb", + "ExpiredTime": -1, + "Quota": 10000000000, + "Unit": "RMB", + "Currency": "RMB" +} +``` + +> `Quota = 10,000,000,000` 表示 100 元(1e-8 元/单位)。 + +## 6. 测试例列表 + +| 编号 | 名称 | 核心验证点 | +|------|------|------------| +| TC-01 | `peak` tier 非流式按 tier 价扣费 | 命中 `peak` 后,Redis 余额按 `prompt*200 + completion*400` 扣减 | +| TC-02 | 无 `Tiers` 按默认价格扣费 | `cluster_no_table` 无 tier,按默认 `Prices` 扣减 | +| TC-03 | `peak` tier + 缓存命中非流式计费 | 后端返回含 `cache_read_tokens`,按 tier cache 拆分公式扣减 | +| TC-04 | `peak` tier + 缓存命中 SSE 流式计费 | SSE 最终 chunk 含 cache usage,按 tier cache 拆分公式扣减 | + +## 7. 公共基础设施改造 + +本场景需要扩展公共测试框架: + +1. **`common/mock_backend.go`**: + - 新增 `ResponseHeaders` 字段,允许测试为 SSE 响应设置 `Content-Type: text/event-stream`。 + - 当响应包含 `text/event-stream` 时,自动使用 chunked 传输(不设置 `Content-Length`),以兼容 `mod_body_process` 对流式响应的处理。 + +## 8. 依赖与风险 + +| 风险点 | 说明 | 缓解措施 | +|--------|------|----------| +| 时间不确定性 | `ActiveTierName` 依赖 `time.Now()` | 将 `peak` 配置为全周全天覆盖 | +| 流式响应被截断 | 若 mock 后端返回 Content-Length,BFE 可能按普通响应处理 | SSE 响应自动使用 chunked | +| RMB 扣减存在异步性 | Redis 扣减在响应发送后执行 | 请求发送后等待并轮询 Redis 余额 | +| ModelTable 价格转换精度 | 浮点到定点整数转换可能与预期有 1 单位误差 | 测试用例使用可整除的价格 | + +## 9. 与其他场景的依赖关系 + +- 依赖 SC01 已验证的 `mod_ai_route` 路由表查找与绑定能力; +- 依赖 SC03 已验证的 RMB 配额扣减基础能力; +- 本场景 focus 在 `mod_ai_token_auth` 的分时段 tier 价格匹配链路。 + +## 10. 参考资料 + +- `bfe/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go` +- `bfe/tests/integration/common/bfe_config_builder.go` +- `bfe/tests/integration/common/mock_backend.go` +- `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` +- `bfe/docs/zh_cn/modifications/2026-08-25-tiered-pricing-support/design-changes.md` diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" index 09feea8f1..7e972e234 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" @@ -31,6 +31,7 @@ | SC06 | Claude 协议支持 | 验证 Anthropic Claude Messages API 请求的识别、鉴权头注入、`anthropic-version` 补全及协议不匹配拒绝 | P0 | 8 | BFE Claude 协议支持 | | SC07 | AI 限流 Redis 键 | 验证 `mod_ai_rate_limit` 使用显式 `redis_key` 进行限流计数,以及规则重命名/模型变更时的计数器连续性 | P0 | 3 | BFE AI 限流 Redis 键 | | SC08 | AI 模型 Fallback 重算 | 验证 cluster 级 fallback 时,fallback cluster 从 `ClientModel` 重新计算目标模型,不继承主 cluster 的映射结果 | P0 | 2 | BFE ProviderModel 前缀裁剪 | +| SC09 | RMB 分时段计费 | 验证 `AIConf.ModelTable` 配置 `Tiers` / `TierPrices` 后,按请求发生时刻匹配 tier 价格并扣减 RMB 配额 | P0 | 4 | BFE RMB 分时段计费 | ## 4. 公共前置条件 @@ -136,6 +137,15 @@ | TC-01 | Fallback 从 ClientModel 重新计算 | P0 | | TC-02 | Fallback 应用自身 strip 配置 | P0 | +### SC09 RMB 分时段计费 + +| 测试例编号 | 测试例名称 | 优先级 | +|------------|------------|--------| +| TC-01 | `peak` tier 非流式按 tier 价扣费 | P0 | +| TC-02 | 无 `Tiers` 按默认价格扣费 | P0 | +| TC-03 | `peak` tier + 缓存命中非流式计费 | P0 | +| TC-04 | `peak` tier + 缓存命中 SSE 流式计费 | P0 | + ## 6. 运行方式 ```bash From 96378eb51cbd30e94eb78e4bb2abb8bd0845c866 Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Tue, 25 Aug 2026 16:58:52 +0800 Subject: [PATCH 11/18] feat: add req_body_larger_than and req_body_less_than condition primitives Add two BFE condition primitives for routing based on HTTP Content-Length: - req_body_larger_than(): matches when body size > threshold - req_body_less_than(): matches when body size < threshold Both primitives return false when Content-Length is absent. Includes implementation, parser registration, unit tests, and docs (zh_cn + en_us). --- bfe_basic/condition/build.go | 29 ++ bfe_basic/condition/build_test.go | 100 ++++++ bfe_basic/condition/parser/semant.go | 2 + bfe_basic/condition/primitive.go | 46 +++ bfe_basic/condition/primitive_test.go | 66 ++++ .../condition/condition_primitive_index.md | 2 + docs/en_us/condition/request/body.md | 42 +++ .../condition/condition_primitive_index.md | 2 + docs/zh_cn/condition/request/body.md | 42 +++ .../design-changes.md | 306 ++++++++++++++++++ 10 files changed, 637 insertions(+) create mode 100644 docs/zh_cn/modifications/2026-08-25-prompt-length-routing/design-changes.md diff --git a/bfe_basic/condition/build.go b/bfe_basic/condition/build.go index 010c6d0f0..1afb2e122 100644 --- a/bfe_basic/condition/build.go +++ b/bfe_basic/condition/build.go @@ -19,6 +19,7 @@ package condition import ( "fmt" "regexp" + "strconv" "strings" ) @@ -567,6 +568,34 @@ func buildPrimitive(node *parser.CallExpr) (Condition, error) { }, matcher: NewPrefixInMatcher(node.Args[1].Value, node.Args[2].ToBool()), }, nil + case "req_body_larger_than": + size, err := strconv.ParseInt(node.Args[0].Value, 10, 64) + if err != nil { + return nil, fmt.Errorf("req_body_larger_than: invalid size %s", node.Args[0].Value) + } + if size < 0 { + return nil, fmt.Errorf("req_body_larger_than: size should not be negative") + } + return &PrimitiveCond{ + name: node.Fun.Name, + node: node, + fetcher: &ContentLengthFetcher{}, + matcher: &GtInt64Matcher{threshold: size}, + }, nil + case "req_body_less_than": + size, err := strconv.ParseInt(node.Args[0].Value, 10, 64) + if err != nil { + return nil, fmt.Errorf("req_body_less_than: invalid size %s", node.Args[0].Value) + } + if size < 0 { + return nil, fmt.Errorf("req_body_less_than: size should not be negative") + } + return &PrimitiveCond{ + name: node.Fun.Name, + node: node, + fetcher: &ContentLengthFetcher{}, + matcher: &LtInt64Matcher{threshold: size}, + }, nil default: return nil, fmt.Errorf("unsupported primitive %s", node.Fun.Name) diff --git a/bfe_basic/condition/build_test.go b/bfe_basic/condition/build_test.go index 567589f5b..c359f20b2 100644 --- a/bfe_basic/condition/build_test.go +++ b/bfe_basic/condition/build_test.go @@ -170,6 +170,50 @@ var buildPrimitiveTests = []struct { }, false, }, + { + "testBuildReqBodyLargerThan", + `req_body_larger_than(8192)`, + &PrimitiveCond{ + name: "req_body_larger_than", + fetcher: &ContentLengthFetcher{}, + matcher: &GtInt64Matcher{threshold: 8192}, + }, + false, + }, + { + "testBuildReqBodyLessThan", + `req_body_less_than(2048)`, + &PrimitiveCond{ + name: "req_body_less_than", + fetcher: &ContentLengthFetcher{}, + matcher: &LtInt64Matcher{threshold: 2048}, + }, + false, + }, + { + "testBuildReqBodyLargerThanInvalid", + `req_body_larger_than("abc")`, + nil, + true, + }, + { + "testBuildReqBodyLargerThanNegative", + `req_body_larger_than(-1)`, + nil, + true, + }, + { + "testBuildReqBodyLessThanInvalid", + `req_body_less_than("abc")`, + nil, + true, + }, + { + "testBuildReqBodyLessThanNegative", + `req_body_less_than(-1)`, + nil, + true, + }, { "testBuildUrlRegMatch", "req_url_regmatch(\"123\")", @@ -438,6 +482,62 @@ func TestBuildTlsClientCAIn(t *testing.T) { } } +func TestBuildReqBodyLargerThanMatch(t *testing.T) { + cond, err := Build(`req_body_larger_than(100)`) + if err != nil { + t.Fatalf("build failed: %v", err) + } + + req.HttpRequest.Header = make(map[string][]string) + req.HttpRequest.Header.Set("Content-Length", "101") + if !cond.Match(&req) { + t.Errorf("Content-Length 101 should match req_body_larger_than(100)") + } + + req.HttpRequest.Header.Set("Content-Length", "100") + if cond.Match(&req) { + t.Errorf("Content-Length 100 should not match req_body_larger_than(100)") + } + + req.HttpRequest.Header.Set("Content-Length", "99") + if cond.Match(&req) { + t.Errorf("Content-Length 99 should not match req_body_larger_than(100)") + } + + req.HttpRequest.Header.Del("Content-Length") + if cond.Match(&req) { + t.Errorf("missing Content-Length should not match req_body_larger_than(100)") + } +} + +func TestBuildReqBodyLessThanMatch(t *testing.T) { + cond, err := Build(`req_body_less_than(100)`) + if err != nil { + t.Fatalf("build failed: %v", err) + } + + req.HttpRequest.Header = make(map[string][]string) + req.HttpRequest.Header.Set("Content-Length", "99") + if !cond.Match(&req) { + t.Errorf("Content-Length 99 should match req_body_less_than(100)") + } + + req.HttpRequest.Header.Set("Content-Length", "100") + if cond.Match(&req) { + t.Errorf("Content-Length 100 should not match req_body_less_than(100)") + } + + req.HttpRequest.Header.Set("Content-Length", "101") + if cond.Match(&req) { + t.Errorf("Content-Length 101 should not match req_body_less_than(100)") + } + + req.HttpRequest.Header.Del("Content-Length") + if cond.Match(&req) { + t.Errorf("missing Content-Length should not match req_body_less_than(100)") + } +} + func TestBuildHostTagIn(t *testing.T) { cond, err := Build("req_host_tag_in(\"host_tag1|host_tag2\")") if err != nil { diff --git a/bfe_basic/condition/parser/semant.go b/bfe_basic/condition/parser/semant.go index 35a8a0131..7b7b9aa84 100644 --- a/bfe_basic/condition/parser/semant.go +++ b/bfe_basic/condition/parser/semant.go @@ -81,6 +81,8 @@ var funcProtos = map[string][]Token{ "bfe_periodic_time_range": []Token{STRING, STRING, STRING}, "req_body_json_in": []Token{STRING, STRING, BOOL}, "req_body_json_prefix_in": []Token{STRING, STRING, BOOL}, + "req_body_larger_than": []Token{INT}, + "req_body_less_than": []Token{INT}, } func prototypeCheck(expr *CallExpr) error { diff --git a/bfe_basic/condition/primitive.go b/bfe_basic/condition/primitive.go index 37a632cc4..149623bf2 100644 --- a/bfe_basic/condition/primitive.go +++ b/bfe_basic/condition/primitive.go @@ -1173,6 +1173,52 @@ func HttpRespBodyJsonGet(res *bfe_http.Response, path string) (string, error) { return val.String(), nil } +// ContentLengthFetcher fetches Content-Length from request header. +type ContentLengthFetcher struct{} + +func (f *ContentLengthFetcher) Fetch(req *bfe_basic.Request) (interface{}, error) { + if req == nil || req.HttpRequest == nil { + return nil, fmt.Errorf("fetcher: nil pointer") + } + + cl := req.HttpRequest.Header.Get("Content-Length") + if cl == "" { + return nil, fmt.Errorf("fetcher: Content-Length absent") + } + + n, err := strconv.ParseInt(cl, 10, 64) + if err != nil { + return nil, fmt.Errorf("fetcher: invalid Content-Length %s", cl) + } + return n, nil +} + +// GtInt64Matcher matches when value > threshold. +type GtInt64Matcher struct { + threshold int64 +} + +func (m *GtInt64Matcher) Match(v interface{}) bool { + n, ok := v.(int64) + if !ok { + return false + } + return n > m.threshold +} + +// LtInt64Matcher matches when value < threshold. +type LtInt64Matcher struct { + threshold int64 +} + +func (m *LtInt64Matcher) Match(v interface{}) bool { + n, ok := v.(int64) + if !ok { + return false + } + return n < m.threshold +} + func ReqBodyJsonSet(req *bfe_basic.Request, path string, value string) error { if req == nil || req.OutRequest == nil { return fmt.Errorf("set json body error: nil pointer") diff --git a/bfe_basic/condition/primitive_test.go b/bfe_basic/condition/primitive_test.go index 1aa33e0ee..1e2252231 100644 --- a/bfe_basic/condition/primitive_test.go +++ b/bfe_basic/condition/primitive_test.go @@ -17,6 +17,7 @@ package condition import ( "net" "net/http" + "strconv" "strings" "testing" "time" @@ -414,6 +415,71 @@ func TestReqBodyJsonPrefixInMulti(t *testing.T) { } } +func TestContentLengthFetcher(t *testing.T) { + body := `{"model":"gpt-4"}` + httpReq, _ := bfe_http.NewRequest("POST", "http://example.com/v1/chat/completions", strings.NewReader(body)) + httpReq.Header.Set("Content-Length", strconv.Itoa(len(body))) + req := bfe_basic.NewRequest(httpReq, nil, nil, &bfe_basic.Session{}, nil) + + f := &ContentLengthFetcher{} + v, err := f.Fetch(req) + if err != nil { + t.Fatalf("Fetch() error: %v", err) + } + if v.(int64) != int64(len(body)) { + t.Errorf("Content-Length mismatch, want=%d, got=%d", len(body), v.(int64)) + } + + // missing Content-Length + httpReq2, _ := bfe_http.NewRequest("POST", "http://example.com/v1/chat/completions", strings.NewReader(`body`)) + req2 := bfe_basic.NewRequest(httpReq2, nil, nil, &bfe_basic.Session{}, nil) + _, err = f.Fetch(req2) + if err == nil { + t.Errorf("missing Content-Length should return error") + } + + // invalid Content-Length + httpReq3, _ := bfe_http.NewRequest("POST", "http://example.com/v1/chat/completions", strings.NewReader(`body`)) + httpReq3.Header.Set("Content-Length", "abc") + req3 := bfe_basic.NewRequest(httpReq3, nil, nil, &bfe_basic.Session{}, nil) + _, err = f.Fetch(req3) + if err == nil { + t.Errorf("invalid Content-Length should return error") + } +} + +func TestGtInt64Matcher(t *testing.T) { + m := &GtInt64Matcher{threshold: 100} + if !m.Match(int64(101)) { + t.Errorf("101 should match >100") + } + if m.Match(int64(100)) { + t.Errorf("100 should not match >100") + } + if m.Match(int64(99)) { + t.Errorf("99 should not match >100") + } + if m.Match("abc") { + t.Errorf("string should not match GtInt64Matcher") + } +} + +func TestLtInt64Matcher(t *testing.T) { + m := &LtInt64Matcher{threshold: 100} + if !m.Match(int64(99)) { + t.Errorf("99 should match <100") + } + if m.Match(int64(100)) { + t.Errorf("100 should not match <100") + } + if m.Match(int64(101)) { + t.Errorf("101 should not match <100") + } + if m.Match("abc") { + t.Errorf("string should not match LtInt64Matcher") + } +} + func TestReqBodyJsonPrefixInCombineWithIn(t *testing.T) { cond, err := Build(`req_body_json_in("model", "gpt-4", false) && req_body_json_prefix_in("model", "gpt-", false)`) if err != nil { diff --git a/docs/en_us/condition/condition_primitive_index.md b/docs/en_us/condition/condition_primitive_index.md index 6029dc105..65c6b5f2e 100644 --- a/docs/en_us/condition/condition_primitive_index.md +++ b/docs/en_us/condition/condition_primitive_index.md @@ -90,6 +90,8 @@ * [req_body_json_in(json_path, value_list, case_insensitive)](./request/body.md#req_body_json_injson_path-value_list-case_insensitive) * [req_body_json_prefix_in(json_path, value_prefix_list, case_insensitive)](./request/body.md#req_body_json_prefix_injson_path-value_prefix_list-case_insensitive) + * [req_body_larger_than(size)](./request/body.md#req_body_larger_thansize) + * [req_body_less_than(size)](./request/body.md#req_body_less_thansize) ## Response Primitive diff --git a/docs/en_us/condition/request/body.md b/docs/en_us/condition/request/body.md index 5afeaf1aa..ea4255862 100644 --- a/docs/en_us/condition/request/body.md +++ b/docs/en_us/condition/request/body.md @@ -40,3 +40,45 @@ req_body_json_prefix_in("model", "openrouter/anthropic/", false) // Match all models starting with gpt- or claude- (case-insensitive) req_body_json_prefix_in("model", "gpt-|claude-", true) ``` + +## req_body_larger_than(size) + +* Meaning: Checks whether the value of the `Content-Length` request header is strictly greater than `size` (unit: bytes). +* Parameters + +| Parameter | Description | +| --------- | ----------------- | +| size | Integer
Threshold in bytes | + +* Notes + * The data source is the HTTP `Content-Length` header, which reflects the total size of the HTTP body in bytes, not the pure prompt text length. + * If the request has no `Content-Length` header (e.g., chunked requests), this primitive returns `false`. + * Calibrate the threshold against real traffic and reserve margin for the fixed overhead of the JSON structure. + +* Example + +```go +// Match when the request body is larger than 8KB +req_body_larger_than(8192) +``` + +## req_body_less_than(size) + +* Meaning: Checks whether the value of the `Content-Length` request header is strictly less than `size` (unit: bytes). +* Parameters + +| Parameter | Description | +| --------- | ----------------- | +| size | Integer
Threshold in bytes | + +* Notes + * The data source is the HTTP `Content-Length` header, which reflects the total size of the HTTP body in bytes, not the pure prompt text length. + * If the request has no `Content-Length` header (e.g., chunked requests), this primitive returns `false`. + * Calibrate the threshold against real traffic and reserve margin for the fixed overhead of the JSON structure. + +* Example + +```go +// Match when the request body is smaller than 2KB +req_body_less_than(2048) +``` diff --git a/docs/zh_cn/condition/condition_primitive_index.md b/docs/zh_cn/condition/condition_primitive_index.md index 0291b0175..4d859be23 100644 --- a/docs/zh_cn/condition/condition_primitive_index.md +++ b/docs/zh_cn/condition/condition_primitive_index.md @@ -90,6 +90,8 @@ * [req_body_json_in(json_path, value_list, case_insensitive)](./request/body.md#req_body_json_injson_path-value_list-case_insensitive) * [req_body_json_prefix_in(json_path, value_prefix_list, case_insensitive)](./request/body.md#req_body_json_prefix_injson_path-value_prefix_list-case_insensitive) + * [req_body_larger_than(size)](./request/body.md#req_body_larger_thansize) + * [req_body_less_than(size)](./request/body.md#req_body_less_thansize) ## 响应相关 diff --git a/docs/zh_cn/condition/request/body.md b/docs/zh_cn/condition/request/body.md index 7de885f58..6b56bdfae 100644 --- a/docs/zh_cn/condition/request/body.md +++ b/docs/zh_cn/condition/request/body.md @@ -40,3 +40,45 @@ req_body_json_prefix_in("model", "openrouter/anthropic/", false) // 命中所有 gpt- 或 claude- 开头的模型(大小写不敏感) req_body_json_prefix_in("model", "gpt-|claude-", true) ``` + +## req_body_larger_than(size) + +* 含义: 判断请求头 `Content-Length` 的值是否严格大于 `size`(单位:字节) +* 参数 + +| 参数 | 描述 | +| -------- | ---------------------- | +| size | Integer
字节数阈值 | + +* 说明 + * 数据来源于 HTTP 请求头 `Content-Length`,反映的是整个 HTTP body 的字节数,不是纯 prompt 文本长度; + * 若请求没有 `Content-Length` 头(如 chunked 请求),该原语返回 `false`; + * 配置阈值时建议通过实际请求采样校准,预留 JSON 结构本身的固定开销。 + +* 示例 + +```go +// 请求体大于 8KB 时命中 +req_body_larger_than(8192) +``` + +## req_body_less_than(size) + +* 含义: 判断请求头 `Content-Length` 的值是否严格小于 `size`(单位:字节) +* 参数 + +| 参数 | 描述 | +| -------- | ---------------------- | +| size | Integer
字节数阈值 | + +* 说明 + * 数据来源于 HTTP 请求头 `Content-Length`,反映的是整个 HTTP body 的字节数,不是纯 prompt 文本长度; + * 若请求没有 `Content-Length` 头(如 chunked 请求),该原语返回 `false`; + * 配置阈值时建议通过实际请求采样校准,预留 JSON 结构本身的固定开销。 + +* 示例 + +```go +// 请求体小于 2KB 时命中 +req_body_less_than(2048) +``` diff --git a/docs/zh_cn/modifications/2026-08-25-prompt-length-routing/design-changes.md b/docs/zh_cn/modifications/2026-08-25-prompt-length-routing/design-changes.md new file mode 100644 index 000000000..a846516bd --- /dev/null +++ b/docs/zh_cn/modifications/2026-08-25-prompt-length-routing/design-changes.md @@ -0,0 +1,306 @@ +# BFE 支持按 Prompt 长度路由(基于 Content-Length) + +## 1. 背景 + +AI 网关客户提出希望根据输入 prompt 长度执行路由策略: + +- 长文本请求路由到擅长长上下文的集群/模型; +- 短文本请求路由到成本更低、响应更快的集群/模型。 + +由于 BFE 对 AI 请求采用流式转发,在转发开始时通常无法确定完整 body 大小,直接解析 body 计算 prompt 长度会受 `AccessibleBodySize` 限制且开销较大。本变更采用新的思路:**直接利用 HTTP `Content-Length` 头判断请求体字节数**,作为 prompt 长度的代理指标,新增两个条件原语 `req_body_larger_than` 和 `req_body_less_than`,在不影响流式转发的前提下支持按 body 大小路由。 + +--- + +## 2. 需求示例 + +某 apikey 希望将长文本请求路由到长上下文集群,短文本请求路由到默认集群: + +```json +{ + "Version": "1.0", + "route_rules": { + "apikey_ak_user_a": { + "type": "apikey", + "owner": "ak_user_a", + "rules": [ + { + "name": "long-body-route", + "Cond": "req_body_larger_than(8192)", + "targets": [ + {"cluster": "cluster_long_context", "model": "", "weight": 100} + ], + "fallbacks": [] + }, + { + "name": "short-body-route", + "Cond": "req_body_less_than(2048)", + "targets": [ + {"cluster": "cluster_fast_cheap", "model": "", "weight": 100} + ], + "fallbacks": [] + }, + { + "name": "default", + "Cond": "default_t()", + "targets": [ + {"cluster": "cluster_default", "model": "", "weight": 100} + ], + "fallbacks": [] + } + ] + } + }, + "ApikeyRouteTableBindings": { + "ak_user_a": ["apikey_ak_user_a"] + } +} +``` + +> 说明:`req_body_larger_than(8192)` 表示整个 HTTP body 大于 8192 字节,实际 prompt 文本会略小于此值(扣除 JSON 字段开销)。配置阈值时建议通过实际请求采样校准。 + +--- + +## 3. 当前现状 + +| 层级 | 当前能力 | 不足 | +|---|---|---| +| 条件原语 | 支持 `req_host_in`、`req_header_value_in`、`req_body_json_in`、`default_t` 等 | 没有基于请求体大小的条件原语 | +| 路由规则 | `mod_ai_route` 在 `HandleFoundProduct` 阶段执行,可基于条件表达式选择集群 | 无法按 prompt/body 长度做路由决策 | +| 请求处理 | HTTP 头(含 `Content-Length`)在路由阶段已解析完成 | 未暴露 body 大小给条件系统 | +| 流式转发 | BFE 只缓存部分 body(`AccessibleBodySize`),支持流式转发 | 无法依赖完整 body 解析获取 prompt 长度 | + +--- + +## 4. 变更目标 + +1. 在 BFE 条件系统中新增两个原语:`req_body_larger_than()` 和 `req_body_less_than()`。 +2. 原语基于 HTTP `Content-Length` 头判断请求体字节数,单位:字节。 +3. 无 `Content-Length` 头时两个原语均返回 `false`(不匹配),行为明确且安全。 +4. 支持与其他条件原语组合使用(如 `req_host_in(...) && req_body_larger_than(8192)`)。 +5. 新增单元测试与集成测试,覆盖有/无 `Content-Length`、边界值、组合条件等场景。 + +--- + +## 5. 变更总览 + +| 模块 | 主要改动 | +|---|---| +| `bfe_basic/condition` | 新增 `ContentLengthFetcher`、`GtInt64Matcher`、`LtInt64Matcher`;在 `build.go` 注册两个新原语 | +| `bfe_basic/condition/primitive.go` | 实现 Fetcher 与 Matcher | +| `bfe_basic/condition/build.go` | 增加 `req_body_larger_than` / `req_body_less_than` 的 case | +| `bfe/docs/zh_cn/condition/request/body.md` | 补充新原语文档 | +| 测试 | 补充条件原语单元测试、按 body 大小路由集成测试 | + +--- + +## 6. 详细设计 + +### 6.1 新增条件原语 + +**文件:** `bfe/bfe_basic/condition/build.go` + +在 `Build` 函数的条件原语分发中新增两个 case: + +```go +case "req_body_larger_than": + size, err := strconv.ParseInt(node.Args[0].Value, 10, 64) + if err != nil { + return nil, fmt.Errorf("req_body_larger_than: invalid size %s", node.Args[0].Value) + } + return &PrimitiveCond{ + name: node.Fun.Name, + node: node, + fetcher: &ContentLengthFetcher{}, + matcher: &GtInt64Matcher{threshold: size}, + }, nil + +case "req_body_less_than": + size, err := strconv.ParseInt(node.Args[0].Value, 10, 64) + if err != nil { + return nil, fmt.Errorf("req_body_less_than: invalid size %s", node.Args[0].Value) + } + return &PrimitiveCond{ + name: node.Fun.Name, + node: node, + fetcher: &ContentLengthFetcher{}, + matcher: &LtInt64Matcher{threshold: size}, + }, nil +``` + +### 6.2 实现 Fetcher 与 Matcher + +**文件:** `bfe/bfe_basic/condition/primitive.go` + +```go +// ContentLengthFetcher 从请求头读取 Content-Length +type ContentLengthFetcher struct{} + +func (f *ContentLengthFetcher) Fetch(req *bfe_basic.Request) (interface{}, error) { + if req == nil || req.HttpRequest == nil { + return nil, fmt.Errorf("fetcher: nil pointer") + } + + cl := req.HttpRequest.Header.Get("Content-Length") + if cl == "" { + return nil, fmt.Errorf("fetcher: Content-Length absent") + } + + n, err := strconv.ParseInt(cl, 10, 64) + if err != nil { + return nil, fmt.Errorf("fetcher: invalid Content-Length %s", cl) + } + return n, nil +} + +// GtInt64Matcher: value > threshold +type GtInt64Matcher struct { + threshold int64 +} + +func (m *GtInt64Matcher) Match(v interface{}) bool { + n, ok := v.(int64) + if !ok { + return false + } + return n > m.threshold +} + +// LtInt64Matcher: value < threshold +type LtInt64Matcher struct { + threshold int64 +} + +func (m *LtInt64Matcher) Match(v interface{}) bool { + n, ok := v.(int64) + if !ok { + return false + } + return n < m.threshold +} +``` + +Fetcher 返回 `error` 时,`PrimitiveCond.Match` 会按 `false` 处理,因此无 `Content-Length` 的请求自动不匹配。 + +### 6.3 条件原语语义 + +| 原语 | 参数 | 命中条件 | 无 `Content-Length` 时 | +|---|---|---|---| +| `req_body_larger_than(N)` | 字节数(整数) | `Content-Length > N` | 返回 `false` | +| `req_body_less_than(N)` | 字节数(整数) | `Content-Length < N` | 返回 `false` | + +### 6.4 与现有路由链路的集成 + +`mod_ai_route` 注册在 `HandleFoundProduct` 阶段。在此阶段,HTTP 头(包括 `Content-Length`)已经解析完成,新增原语可直接参与 `ai_route.data` 条件表达式的匹配,不改变后续加权选择、fallback、计费逻辑。 + +--- + +## 7. 边界情况与兼容性 + +| 场景 | 处理建议 | +|---|---| +| 请求无 `Content-Length` 头 | 两个原语均返回 `false`,走默认路由或后续规则 | +| `Content-Length` 为非法值 | Fetcher 返回 error,`PrimitiveCond.Match` 按 `false` 处理 | +| 阈值为负数或非法整数 | `condition.Build` 阶段报错,配置加载失败 | +| `Content-Length` 等于阈值 | `req_body_larger_than` 不命中(严格大于),`req_body_less_than` 不命中(严格小于) | +| 与 `default_t()` 组合 | 按 BFE 条件系统常规优先级执行 | +| chunked 请求 | 通常无 `Content-Length`,两个原语不匹配 | + +--- + +## 8. 测试计划 + +### 8.1 单元测试 + +在 `bfe/bfe_basic/condition/` 中新增或扩展测试: + +1. `TestReqBodyLargerThan`: + - `Content-Length` 大于阈值时命中; + - `Content-Length` 小于/等于阈值时不命中; + - 无 `Content-Length` 时不命中; + - 非法 `Content-Length` 时不命中。 + +2. `TestReqBodyLessThan`: + - `Content-Length` 小于阈值时命中; + - `Content-Length` 大于/等于阈值时不命中; + - 无 `Content-Length` 时不命中; + - 非法 `Content-Length` 时不命中。 + +3. `TestContentLengthFetcher`: + - 验证正常读取、缺失、非法值三种情况。 + +### 8.2 集成测试 + +新增独立 scenario `bfe/tests/integration/implementation/scenario-SC10-prompt-length-routing/`,主要用例: + +1. `TestTC01_LongBodyRoute`: + - 后端/模拟请求带 `Content-Length: 10000`; + - `ai_route.data` 配置 `req_body_larger_than(8192)` 指向 `cluster_long_context`; + - 验证请求被路由到 `cluster_long_context`。 + +2. `TestTC02_ShortBodyRoute`: + - 模拟请求带 `Content-Length: 1024`; + - `ai_route.data` 配置 `req_body_less_than(2048)` 指向 `cluster_fast_cheap`; + - 验证请求被路由到 `cluster_fast_cheap`。 + +3. `TestTC03_NoContentLengthFallback`: + - 模拟请求不带 `Content-Length`(如 chunked); + - 验证走 `default_t()` 默认路由。 + +运行方式: + +```bash +go test ./tests/integration/implementation/scenario-SC10-prompt-length-routing/... +``` + +--- + +## 9. 实施步骤建议 + +1. **条件系统扩展**:在 `bfe_basic/condition/primitive.go` 实现 `ContentLengthFetcher`、`GtInt64Matcher`、`LtInt64Matcher`;在 `build.go` 注册两个新原语。 +2. **单元测试**:补充 Fetcher、Matcher、Build 阶段校验测试。 +3. **文档更新**:更新 `bfe/docs/zh_cn/condition/request/body.md`,说明新原语语义与使用限制。 +4. **集成测试**:新增 SC10 集成测试场景,验证按 body 大小路由到不同 cluster。 +5. **回归验证**:确认现有路由、计费、fallback 场景不受影响。 + +--- + +## 10. 影响范围 + +| 模块/文件 | 影响 | +|---|---| +| `bfe/bfe_basic/condition/build.go` | 新增两个条件原语的分支 | +| `bfe/bfe_basic/condition/primitive.go` | 新增 `ContentLengthFetcher`、`GtInt64Matcher`、`LtInt64Matcher` | +| `bfe/bfe_basic/condition/build_test.go` 或相关测试文件 | 新增原语单元测试 | +| `bfe/docs/zh_cn/condition/request/body.md` | 补充新原语文档 | +| `bfe/tests/integration/implementation/scenario-SC10-prompt-length-routing/` | 新增集成测试场景 | +| `bfe/docs/zh_cn/modifications/2026-08-25-prompt-length-routing/design-changes.md` | 本设计变更文档 | + +--- + +## 11. 兼容性与风险 + +### 11.1 兼容性 + +- 不修改现有条件原语语义,已有 `ai_route.data` 配置完全兼容。 +- 不修改路由、加权选择、fallback、计费逻辑。 +- 新增原语为可选能力,未使用时行为不变。 + +### 11.2 风险与缓解 + +| 风险 | 缓解措施 | +|---|---| +| `Content-Length` 不等于纯 prompt 长度(包含 JSON 结构开销) | 文档明确说明这是 body 字节数代理指标,建议通过采样校准阈值 | +| 无 `Content-Length` 时原语不匹配 | 文档说明依赖条件;chunked 请求走默认路由 | +| 多模态 base64 导致 body 偏大 | 文档说明阈值需留出余量;未来可叠加二期精确解析方案 | +| 负数/非法阈值导致配置加载失败 | `Build` 阶段做参数校验,非法配置明确报错 | + +--- + +## 12. 参考资料 + +- `bfe/bfe_basic/condition/build.go` +- `bfe/bfe_basic/condition/primitive.go` +- `bfe/bfe_modules/mod_ai_route/mod_ai_route.go` +- `bfe/bfe_modules/mod_ai_route/route_rule.go` +- `bfe/bfe_basic/request_ai_basic.go` +- `bfe/bfe_basic/request_ai_route.go` From 2286a31f46a92967712dbddf1d7a3c027721f537 Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Wed, 26 Aug 2026 09:48:15 +0800 Subject: [PATCH 12/18] =?UTF-8?q?feat:=20=E5=90=8C=20cluster=20=E5=A4=9A?= =?UTF-8?q?=20API-Key=20=E4=BC=9A=E8=AF=9D=E7=BA=A7=20Key=20=E4=BA=B2?= =?UTF-8?q?=E5=92=8C=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 reverseproxy 中实现基于 Redis 的会话级 API-Key 绑定 - cluster_conf 新增 KeyAffinity/KeyAffinityRedis 配置解析 - 更新 multi_api_key 系统设计文档与中英文 configuration 文档 - 新增 modifications 修改说明文档 - 新增 SC10 集成测试及测试设计文档 - 新增 bfe-rd-workflow Skill 到多个 Agent 目录 --- .agents/skills/bfe-rd-workflow/SKILL.md | 198 ++++++ .claude/skills/bfe-rd-workflow/SKILL.md | 198 ++++++ .cursor/skills/bfe-rd-workflow/SKILL.md | 198 ++++++ .trae/skills/bfe-rd-workflow/SKILL.md | 198 ++++++ .../cluster_conf/cluster_conf_load.go | 35 ++ .../cluster_conf/cluster_conf_load_test.go | 30 + .../mod_ai_rate_limit/mod_ai_rate_limit.go | 4 + bfe_server/proxy_state.go | 7 + bfe_server/reverseproxy.go | 228 ++++++- bfe_server/reverseproxy_ai_test.go | 300 +++++++++ .../mod_ai_rate_limit.conf.md | 2 + .../server_data_conf/cluster_conf.data.md | 18 +- .../mod_ai_rate_limit.conf.md | 2 + .../server_data_conf/cluster_conf.data.md | 24 +- .../design-changes.md | 323 ++++++++++ docs/zh_cn/sys_design/multi_api_key.md | 149 ++++- skills/bfe-rd-workflow/SKILL.md | 198 ++++++ tests/integration/common/redis_server.go | 5 + .../sc10_ai_key_session_affinity_test.go | 577 ++++++++++++++++++ .../testdata/bfe.conf | 38 ++ .../testdata/cluster_conf/gslb.data | 10 + .../mod_ai_rate_limit/mod_ai_rate_limit.conf | 14 + .../testdata/mod_ai_route/ai_route.data | 31 + .../testdata/mod_ai_route/mod_ai_route.conf | 5 + .../mod_ai_token_auth/mod_ai_token_auth.conf | 13 + .../testdata/server_data_conf/host_rule.data | 13 + .../testdata/server_data_conf/route_rule.data | 12 + .../testdata/server_data_conf/vip_rule.data | 8 + .../testdata/tls_conf/backend_rs/bfe_i_ca.crt | 23 + .../testdata/tls_conf/backend_rs/bfe_r_ca.crt | 22 + .../tls_conf/backend_rs/r_bfe_dev.crt | 85 +++ .../tls_conf/backend_rs/r_bfe_dev_prv.pem | 27 + .../tls_conf/backend_rs/r_san_example.org.crt | 85 +++ .../backend_rs/r_san_example.org_prv.pem | 27 + .../testdata/tls_conf/certs/example.crt | 19 + .../testdata/tls_conf/certs/example.key | 27 + .../tls_conf/client_ca/example_ca.crt | 19 + .../tls_conf/client_ca/example_ca.key | 27 + .../testdata/tls_conf/server_cert_conf.data | 12 + .../testdata/tls_conf/session_ticket_key.data | 4 + .../testdata/tls_conf/tls_rule_conf.data | 20 + ...344\270\255\345\220\214\344\270\200Key.md" | 59 ++ ...345\210\260\345\244\232\344\270\252Key.md" | 53 ++ ...15\346\226\260\347\273\221\345\256\232.md" | 60 ++ ...0\277\207Redis\347\273\221\345\256\232.md" | 52 ++ ...54\347\253\213\347\273\221\345\256\232.md" | 58 ++ ...21\345\256\232\344\277\235\346\214\201.md" | 59 ++ ...72\346\231\257\350\257\264\346\230\216.md" | 228 +++++++ ...73\344\275\223\350\257\264\346\230\216.md" | 12 + 49 files changed, 3806 insertions(+), 10 deletions(-) create mode 100644 .agents/skills/bfe-rd-workflow/SKILL.md create mode 100644 .claude/skills/bfe-rd-workflow/SKILL.md create mode 100644 .cursor/skills/bfe-rd-workflow/SKILL.md create mode 100644 .trae/skills/bfe-rd-workflow/SKILL.md create mode 100644 docs/zh_cn/modifications/2026-08-26-ai-key-session-affinity/design-changes.md create mode 100644 skills/bfe-rd-workflow/SKILL.md create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/sc10_ai_key_session_affinity_test.go create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/bfe.conf create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/cluster_conf/gslb.data create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_rate_limit/mod_ai_rate_limit.conf create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_route/ai_route.data create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_route/mod_ai_route.conf create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_token_auth/mod_ai_token_auth.conf create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/server_data_conf/host_rule.data create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/server_data_conf/route_rule.data create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/server_data_conf/vip_rule.data create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/bfe_i_ca.crt create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/bfe_r_ca.crt create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_bfe_dev.crt create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_san_example.org.crt create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/certs/example.crt create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/certs/example.key create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/client_ca/example_ca.crt create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/client_ca/example_ca.key create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/server_cert_conf.data create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/session_ticket_key.data create mode 100644 tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/tls_rule_conf.data create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-01-\345\274\200\345\220\257\344\272\262\345\222\214\346\200\247\346\227\266\345\220\214\344\270\200ClientKeyId\345\221\275\344\270\255\345\220\214\344\270\200Key.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-02-\345\205\263\351\227\255\344\272\262\345\222\214\346\200\247\346\227\266\350\257\267\346\261\202\345\210\206\346\225\243\345\210\260\345\244\232\344\270\252Key.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-03-\344\272\262\345\222\214Key\350\277\224\345\233\236429\345\220\216\350\275\256\346\215\242\345\271\266\351\207\215\346\226\260\347\273\221\345\256\232.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-04-\345\215\225Key-cluster\350\267\263\350\277\207Redis\347\273\221\345\256\232.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-05-\344\270\215\345\220\214ClientKeyId\347\213\254\347\253\213\347\273\221\345\256\232.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-06-BFE\351\207\215\345\220\257\345\220\216\347\273\221\345\256\232\344\277\235\346\214\201.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/\345\234\272\346\231\257\350\257\264\346\230\216.md" diff --git a/.agents/skills/bfe-rd-workflow/SKILL.md b/.agents/skills/bfe-rd-workflow/SKILL.md new file mode 100644 index 000000000..b0e852421 --- /dev/null +++ b/.agents/skills/bfe-rd-workflow/SKILL.md @@ -0,0 +1,198 @@ +--- +name: bfe-rd-workflow +description: 引导用户在 bfe 代码库中完成一次完整的功能研发流程,包括需求对齐、文档修改、代码实现、集成测试与回归验证。 +--- + +# BFE 代码库研发流程 + +本 Skill 适用于在 `bfe/` 目录中新增或修改 BFE 功能(例如 AI Gateway 的多 API-Key 会话亲和性、限流、计费、路由、协议支持等)的完整研发流程。 + +## 触发语 + +当用户提出以下类型请求时启用本流程: + +- “我要实现 xxx 功能” +- “请帮我完成 xxx 的代码与测试” +- “请在 BFE 中增加/修改 xxx” + +## 执行原则 + +1. **分阶段暂停确认**:本流程将研发拆分为多个 Phase。每个 Phase 执行完毕后,必须暂停并等待用户确认“可以继续”后,再进入下一个 Phase。不要在未获得用户确认的情况下自动推进到下一阶段。 +2. **Git 提交前确认**:在任何 `git commit`、`git push` 或其他会改变 Git 仓库状态的操作之前,必须先向用户说明变更内容并取得明确同意。禁止自动执行 Git 提交或推送。 +3. **Git push 默认目标为 origin**:如果获得用户授权执行 `git push`,默认推送到 `origin` 远程仓库,而不是 `upstream`。除非用户明确指定其他 remote,否则不使用 `upstream`。 + +## 研发阶段 + +### Phase 0. 需求澄清与范围界定 + +1. 让用户明确: + - 功能目标(一句话描述) + - 验收标准(必须通过的测试/行为) + - 影响范围(是否改配置协议、是否改 Redis/状态、是否影响既有请求路径) +2. 判断是否为“非平凡改动”(多文件、有架构选择、用户偏好影响实现): + - 是 → 调用 `EnterPlanMode`,先出设计文档/plan 再执行。 + - 否 → 直接进入 Phase 1。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 1. 修改 modifications 文档 + +BFE 侧的改动必须在 `bfe/docs/zh_cn/modifications/` 留下修改说明,便于后续维护与审计。 + +1. 在 `bfe/docs/zh_cn/modifications/` 下新建日期化目录,例如: + ``` + bfe/docs/zh_cn/modifications/2026-08-26-ai-key-session-affinity/ + ``` +2. 在该目录下创建 `design-changes.md`,包含: + - 背景与目标 + - 主要改动点(数据结构、模块行为、新增字段) + - 配置影响 + - 兼容性说明 +3. 如已有同主题目录,则更新其中的文档,不要重复创建。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 2. 更新 configuration 文档 + +如果改动涉及配置文件(`cluster_conf.data`、`mod_*.conf`、`bfe.conf` 等),必须同步更新中英文配置文档: + +1. 中文:`bfe/docs/zh_cn/configuration/` +2. 英文:`bfe/docs/en_us/configuration/` + +更新原则: + +- 新增字段要说明类型、默认值、是否必填、示例。 +- 修改字段要说明前后行为差异。 +- 两个语言目录尽量保持内容一致。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 3. 更新 sys_design 文档 + +如果改动涉及系统设计、协议交互或状态存储,需要更新 `bfe/docs/zh_cn/sys_design/`: + +1. 查找现有相关文档(例如 `multi_api_key.md`)。 +2. 在文档中新增/修改对应章节,说明: + - 方案选型 + - 数据流 + - 状态存储(如 Redis key 格式、TTL) + - 边界情况与优化 +3. 如需新增独立文档,直接创建 `.md` 文件,并在相关文档中建立链接。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 4. 代码实现 + +1. 阅读相关源码,确认: + - 配置结构体(`bfe_config/bfe_cluster_conf/cluster_conf/`) + - 模块加载与处理逻辑(`bfe_modules/mod_*`) + - 反向代理与 Key 选择逻辑(`bfe_server/reverseproxy.go` 及相关测试) +2. 做最小改动,优先匹配现有代码风格: + - 不引入未使用的依赖。 + - 不修改与本次需求无关的逻辑。 +3. 关键实现完成后,先跑模块级单元测试: + ```bash + cd bfe + go test ./bfe_server/... ./bfe_modules/<相关模块>/... + ``` + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 5. 编写集成测试设计文档 + +BFE 的集成测试设计文档位于 `bfe/tests/integration/测试设计文档/`。在写测试代码之前,先补充对应场景的设计文档,便于后续维护与评审。 + +1. 在 `bfe/tests/integration/测试设计文档/` 下新建 scenario 目录,命名规范: + ``` + scenario-SC<序号>-<简短中文描述> + ``` +2. 编写 `场景说明.md`,包含: + - 场景背景与目的 + - 运行模式 + - 涉及的 BFE 配置文件 + - 测试 Cluster 与关键配置(AIConf、KeyPolicy、路由表等) + - 测试例列表 + - 每个测试例的详细说明(目的、前置条件、请求构造、执行步骤、预期结果、清理) + - 公共基础设施 + - 与其他场景的依赖关系 +3. 为每个测试例编写独立的 `TC-<序号>-<测试例名称>.md`,结构与现有 TC 文档保持一致。 +4. 更新 `bfe/tests/integration/测试设计文档/测试场景总体说明.md`: + - 在“场景清单”中新增一行; + - 在“场景与测试例对应关系”中新增该 scenario 的 TC 列表。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 6. 编写集成测试代码 + +BFE 的集成测试代码位于 `bfe/tests/integration/implementation/`。 + +1. 选择最接近的现有 scenario 作为模板(例如需要 Redis 的可参考 `scenario-SC07-ai-rate-limit-redis-key`)。 +2. 新建 scenario 目录,命名规范: + ``` + scenario-SC<序号>-<简短英文描述> + ``` +3. 复制模板后,修改 `testdata/` 中的: + - `server_data_conf/host_rule.data`:host + - `server_data_conf/route_rule.data`:cluster + - `cluster_conf/gslb.data`:cluster 名称 + - `mod_ai_route/ai_route.data`:apikey owner / route binding + - `mod_ai_token_auth/mod_ai_token_auth.conf`、`mod_ai_rate_limit/mod_ai_rate_limit.conf` 等 +4. 编写 `_test.go`,覆盖: + - 正常路径 + - 功能开关开启/关闭对比 + - 边界场景(单 Key、多 ClientKeyId、BFE 重启后状态保持、错误码/降级) +5. 运行集成测试: + ```bash + cd bfe + go test ./tests/integration/implementation/scenario-SC-<描述>/... -v + ``` + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 7. 回归验证 + +1. 运行本次新增 scenario 的测试。 +2. 运行与被改动模块相关的既有 scenario: + - 多 API-Key 轮换:`scenario-SC02-multi-api-key` + - Redis 限流:`scenario-SC07-ai-rate-limit-redis-key` +3. 运行相关单元测试: + ```bash + go test ./bfe_server/... ./bfe_modules/mod_ai_route/... ./bfe_modules/mod_ai_token_auth/... + ``` +4. 如有失败,优先修复;修复后再次回归,直到全部通过。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 8. 收尾与总结 + +1. 检查是否有注释/文档描述的是旧行为,及时同步更新。 +2. 向用户汇报: + - 改动了哪些文件 + - 新增/修改了哪些测试 + - 验证结果 + - 仍存在的风险或待决策点(如有) + +3. **Git 提交前必须人工确认**:如果用户要求或流程需要执行 `git commit`、`git push` 等 Git 操作,必须先向用户清晰说明本次提交内容(包含文件清单与主要变更摘要),并取得明确同意后再执行。禁止在未获授权的情况下自动提交或推送。获得授权后,`git push` 默认推送到 `origin`,不要推送到 `upstream`,除非用户明确指定。 + +## 常见陷阱 + +- **cluster 名称不一致**:`route_rule.data`、`gslb.data`、ai_route 中的 cluster 名称必须一致,否则 BFE 启动会报 `no backend conf`。 +- **ApikeyRouteTableBindings 遗漏**:新增 client apiKey 时,必须在 `mod_ai_route/ai_route.data` 的 `ApikeyRouteTableBindings` 中为其绑定路由表。 +- **Redis 绑定 key 格式**:确认代码中实际使用的 key 格式(如 `bfe:ai:key_affinity::`)。 +- **单 Key 优化**:如果实现了“单 Key 不走 Redis”,需要同时避免读和写,否则集成测试里 `Exists` 会误判。 +- **BFE 端口冲突**:集成测试里 `processEnv.StartBFE` 会自动分配端口,不要手动写死。 + +## 推荐命令速查 + +```bash +# 模块单元测试 +cd bfe +go test ./bfe_server/... ./bfe_modules/mod_ai_route/... ./bfe_modules/mod_ai_token_auth/... + +# 单个集成测试场景(详细日志) +go test ./tests/integration/implementation/scenario-SC-<描述>/... -v + +# 相关场景回归 +go test ./tests/integration/implementation/scenario-SC02-multi-api-key/... +go test ./tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/... +``` diff --git a/.claude/skills/bfe-rd-workflow/SKILL.md b/.claude/skills/bfe-rd-workflow/SKILL.md new file mode 100644 index 000000000..b0e852421 --- /dev/null +++ b/.claude/skills/bfe-rd-workflow/SKILL.md @@ -0,0 +1,198 @@ +--- +name: bfe-rd-workflow +description: 引导用户在 bfe 代码库中完成一次完整的功能研发流程,包括需求对齐、文档修改、代码实现、集成测试与回归验证。 +--- + +# BFE 代码库研发流程 + +本 Skill 适用于在 `bfe/` 目录中新增或修改 BFE 功能(例如 AI Gateway 的多 API-Key 会话亲和性、限流、计费、路由、协议支持等)的完整研发流程。 + +## 触发语 + +当用户提出以下类型请求时启用本流程: + +- “我要实现 xxx 功能” +- “请帮我完成 xxx 的代码与测试” +- “请在 BFE 中增加/修改 xxx” + +## 执行原则 + +1. **分阶段暂停确认**:本流程将研发拆分为多个 Phase。每个 Phase 执行完毕后,必须暂停并等待用户确认“可以继续”后,再进入下一个 Phase。不要在未获得用户确认的情况下自动推进到下一阶段。 +2. **Git 提交前确认**:在任何 `git commit`、`git push` 或其他会改变 Git 仓库状态的操作之前,必须先向用户说明变更内容并取得明确同意。禁止自动执行 Git 提交或推送。 +3. **Git push 默认目标为 origin**:如果获得用户授权执行 `git push`,默认推送到 `origin` 远程仓库,而不是 `upstream`。除非用户明确指定其他 remote,否则不使用 `upstream`。 + +## 研发阶段 + +### Phase 0. 需求澄清与范围界定 + +1. 让用户明确: + - 功能目标(一句话描述) + - 验收标准(必须通过的测试/行为) + - 影响范围(是否改配置协议、是否改 Redis/状态、是否影响既有请求路径) +2. 判断是否为“非平凡改动”(多文件、有架构选择、用户偏好影响实现): + - 是 → 调用 `EnterPlanMode`,先出设计文档/plan 再执行。 + - 否 → 直接进入 Phase 1。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 1. 修改 modifications 文档 + +BFE 侧的改动必须在 `bfe/docs/zh_cn/modifications/` 留下修改说明,便于后续维护与审计。 + +1. 在 `bfe/docs/zh_cn/modifications/` 下新建日期化目录,例如: + ``` + bfe/docs/zh_cn/modifications/2026-08-26-ai-key-session-affinity/ + ``` +2. 在该目录下创建 `design-changes.md`,包含: + - 背景与目标 + - 主要改动点(数据结构、模块行为、新增字段) + - 配置影响 + - 兼容性说明 +3. 如已有同主题目录,则更新其中的文档,不要重复创建。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 2. 更新 configuration 文档 + +如果改动涉及配置文件(`cluster_conf.data`、`mod_*.conf`、`bfe.conf` 等),必须同步更新中英文配置文档: + +1. 中文:`bfe/docs/zh_cn/configuration/` +2. 英文:`bfe/docs/en_us/configuration/` + +更新原则: + +- 新增字段要说明类型、默认值、是否必填、示例。 +- 修改字段要说明前后行为差异。 +- 两个语言目录尽量保持内容一致。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 3. 更新 sys_design 文档 + +如果改动涉及系统设计、协议交互或状态存储,需要更新 `bfe/docs/zh_cn/sys_design/`: + +1. 查找现有相关文档(例如 `multi_api_key.md`)。 +2. 在文档中新增/修改对应章节,说明: + - 方案选型 + - 数据流 + - 状态存储(如 Redis key 格式、TTL) + - 边界情况与优化 +3. 如需新增独立文档,直接创建 `.md` 文件,并在相关文档中建立链接。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 4. 代码实现 + +1. 阅读相关源码,确认: + - 配置结构体(`bfe_config/bfe_cluster_conf/cluster_conf/`) + - 模块加载与处理逻辑(`bfe_modules/mod_*`) + - 反向代理与 Key 选择逻辑(`bfe_server/reverseproxy.go` 及相关测试) +2. 做最小改动,优先匹配现有代码风格: + - 不引入未使用的依赖。 + - 不修改与本次需求无关的逻辑。 +3. 关键实现完成后,先跑模块级单元测试: + ```bash + cd bfe + go test ./bfe_server/... ./bfe_modules/<相关模块>/... + ``` + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 5. 编写集成测试设计文档 + +BFE 的集成测试设计文档位于 `bfe/tests/integration/测试设计文档/`。在写测试代码之前,先补充对应场景的设计文档,便于后续维护与评审。 + +1. 在 `bfe/tests/integration/测试设计文档/` 下新建 scenario 目录,命名规范: + ``` + scenario-SC<序号>-<简短中文描述> + ``` +2. 编写 `场景说明.md`,包含: + - 场景背景与目的 + - 运行模式 + - 涉及的 BFE 配置文件 + - 测试 Cluster 与关键配置(AIConf、KeyPolicy、路由表等) + - 测试例列表 + - 每个测试例的详细说明(目的、前置条件、请求构造、执行步骤、预期结果、清理) + - 公共基础设施 + - 与其他场景的依赖关系 +3. 为每个测试例编写独立的 `TC-<序号>-<测试例名称>.md`,结构与现有 TC 文档保持一致。 +4. 更新 `bfe/tests/integration/测试设计文档/测试场景总体说明.md`: + - 在“场景清单”中新增一行; + - 在“场景与测试例对应关系”中新增该 scenario 的 TC 列表。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 6. 编写集成测试代码 + +BFE 的集成测试代码位于 `bfe/tests/integration/implementation/`。 + +1. 选择最接近的现有 scenario 作为模板(例如需要 Redis 的可参考 `scenario-SC07-ai-rate-limit-redis-key`)。 +2. 新建 scenario 目录,命名规范: + ``` + scenario-SC<序号>-<简短英文描述> + ``` +3. 复制模板后,修改 `testdata/` 中的: + - `server_data_conf/host_rule.data`:host + - `server_data_conf/route_rule.data`:cluster + - `cluster_conf/gslb.data`:cluster 名称 + - `mod_ai_route/ai_route.data`:apikey owner / route binding + - `mod_ai_token_auth/mod_ai_token_auth.conf`、`mod_ai_rate_limit/mod_ai_rate_limit.conf` 等 +4. 编写 `_test.go`,覆盖: + - 正常路径 + - 功能开关开启/关闭对比 + - 边界场景(单 Key、多 ClientKeyId、BFE 重启后状态保持、错误码/降级) +5. 运行集成测试: + ```bash + cd bfe + go test ./tests/integration/implementation/scenario-SC-<描述>/... -v + ``` + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 7. 回归验证 + +1. 运行本次新增 scenario 的测试。 +2. 运行与被改动模块相关的既有 scenario: + - 多 API-Key 轮换:`scenario-SC02-multi-api-key` + - Redis 限流:`scenario-SC07-ai-rate-limit-redis-key` +3. 运行相关单元测试: + ```bash + go test ./bfe_server/... ./bfe_modules/mod_ai_route/... ./bfe_modules/mod_ai_token_auth/... + ``` +4. 如有失败,优先修复;修复后再次回归,直到全部通过。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 8. 收尾与总结 + +1. 检查是否有注释/文档描述的是旧行为,及时同步更新。 +2. 向用户汇报: + - 改动了哪些文件 + - 新增/修改了哪些测试 + - 验证结果 + - 仍存在的风险或待决策点(如有) + +3. **Git 提交前必须人工确认**:如果用户要求或流程需要执行 `git commit`、`git push` 等 Git 操作,必须先向用户清晰说明本次提交内容(包含文件清单与主要变更摘要),并取得明确同意后再执行。禁止在未获授权的情况下自动提交或推送。获得授权后,`git push` 默认推送到 `origin`,不要推送到 `upstream`,除非用户明确指定。 + +## 常见陷阱 + +- **cluster 名称不一致**:`route_rule.data`、`gslb.data`、ai_route 中的 cluster 名称必须一致,否则 BFE 启动会报 `no backend conf`。 +- **ApikeyRouteTableBindings 遗漏**:新增 client apiKey 时,必须在 `mod_ai_route/ai_route.data` 的 `ApikeyRouteTableBindings` 中为其绑定路由表。 +- **Redis 绑定 key 格式**:确认代码中实际使用的 key 格式(如 `bfe:ai:key_affinity::`)。 +- **单 Key 优化**:如果实现了“单 Key 不走 Redis”,需要同时避免读和写,否则集成测试里 `Exists` 会误判。 +- **BFE 端口冲突**:集成测试里 `processEnv.StartBFE` 会自动分配端口,不要手动写死。 + +## 推荐命令速查 + +```bash +# 模块单元测试 +cd bfe +go test ./bfe_server/... ./bfe_modules/mod_ai_route/... ./bfe_modules/mod_ai_token_auth/... + +# 单个集成测试场景(详细日志) +go test ./tests/integration/implementation/scenario-SC-<描述>/... -v + +# 相关场景回归 +go test ./tests/integration/implementation/scenario-SC02-multi-api-key/... +go test ./tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/... +``` diff --git a/.cursor/skills/bfe-rd-workflow/SKILL.md b/.cursor/skills/bfe-rd-workflow/SKILL.md new file mode 100644 index 000000000..b0e852421 --- /dev/null +++ b/.cursor/skills/bfe-rd-workflow/SKILL.md @@ -0,0 +1,198 @@ +--- +name: bfe-rd-workflow +description: 引导用户在 bfe 代码库中完成一次完整的功能研发流程,包括需求对齐、文档修改、代码实现、集成测试与回归验证。 +--- + +# BFE 代码库研发流程 + +本 Skill 适用于在 `bfe/` 目录中新增或修改 BFE 功能(例如 AI Gateway 的多 API-Key 会话亲和性、限流、计费、路由、协议支持等)的完整研发流程。 + +## 触发语 + +当用户提出以下类型请求时启用本流程: + +- “我要实现 xxx 功能” +- “请帮我完成 xxx 的代码与测试” +- “请在 BFE 中增加/修改 xxx” + +## 执行原则 + +1. **分阶段暂停确认**:本流程将研发拆分为多个 Phase。每个 Phase 执行完毕后,必须暂停并等待用户确认“可以继续”后,再进入下一个 Phase。不要在未获得用户确认的情况下自动推进到下一阶段。 +2. **Git 提交前确认**:在任何 `git commit`、`git push` 或其他会改变 Git 仓库状态的操作之前,必须先向用户说明变更内容并取得明确同意。禁止自动执行 Git 提交或推送。 +3. **Git push 默认目标为 origin**:如果获得用户授权执行 `git push`,默认推送到 `origin` 远程仓库,而不是 `upstream`。除非用户明确指定其他 remote,否则不使用 `upstream`。 + +## 研发阶段 + +### Phase 0. 需求澄清与范围界定 + +1. 让用户明确: + - 功能目标(一句话描述) + - 验收标准(必须通过的测试/行为) + - 影响范围(是否改配置协议、是否改 Redis/状态、是否影响既有请求路径) +2. 判断是否为“非平凡改动”(多文件、有架构选择、用户偏好影响实现): + - 是 → 调用 `EnterPlanMode`,先出设计文档/plan 再执行。 + - 否 → 直接进入 Phase 1。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 1. 修改 modifications 文档 + +BFE 侧的改动必须在 `bfe/docs/zh_cn/modifications/` 留下修改说明,便于后续维护与审计。 + +1. 在 `bfe/docs/zh_cn/modifications/` 下新建日期化目录,例如: + ``` + bfe/docs/zh_cn/modifications/2026-08-26-ai-key-session-affinity/ + ``` +2. 在该目录下创建 `design-changes.md`,包含: + - 背景与目标 + - 主要改动点(数据结构、模块行为、新增字段) + - 配置影响 + - 兼容性说明 +3. 如已有同主题目录,则更新其中的文档,不要重复创建。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 2. 更新 configuration 文档 + +如果改动涉及配置文件(`cluster_conf.data`、`mod_*.conf`、`bfe.conf` 等),必须同步更新中英文配置文档: + +1. 中文:`bfe/docs/zh_cn/configuration/` +2. 英文:`bfe/docs/en_us/configuration/` + +更新原则: + +- 新增字段要说明类型、默认值、是否必填、示例。 +- 修改字段要说明前后行为差异。 +- 两个语言目录尽量保持内容一致。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 3. 更新 sys_design 文档 + +如果改动涉及系统设计、协议交互或状态存储,需要更新 `bfe/docs/zh_cn/sys_design/`: + +1. 查找现有相关文档(例如 `multi_api_key.md`)。 +2. 在文档中新增/修改对应章节,说明: + - 方案选型 + - 数据流 + - 状态存储(如 Redis key 格式、TTL) + - 边界情况与优化 +3. 如需新增独立文档,直接创建 `.md` 文件,并在相关文档中建立链接。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 4. 代码实现 + +1. 阅读相关源码,确认: + - 配置结构体(`bfe_config/bfe_cluster_conf/cluster_conf/`) + - 模块加载与处理逻辑(`bfe_modules/mod_*`) + - 反向代理与 Key 选择逻辑(`bfe_server/reverseproxy.go` 及相关测试) +2. 做最小改动,优先匹配现有代码风格: + - 不引入未使用的依赖。 + - 不修改与本次需求无关的逻辑。 +3. 关键实现完成后,先跑模块级单元测试: + ```bash + cd bfe + go test ./bfe_server/... ./bfe_modules/<相关模块>/... + ``` + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 5. 编写集成测试设计文档 + +BFE 的集成测试设计文档位于 `bfe/tests/integration/测试设计文档/`。在写测试代码之前,先补充对应场景的设计文档,便于后续维护与评审。 + +1. 在 `bfe/tests/integration/测试设计文档/` 下新建 scenario 目录,命名规范: + ``` + scenario-SC<序号>-<简短中文描述> + ``` +2. 编写 `场景说明.md`,包含: + - 场景背景与目的 + - 运行模式 + - 涉及的 BFE 配置文件 + - 测试 Cluster 与关键配置(AIConf、KeyPolicy、路由表等) + - 测试例列表 + - 每个测试例的详细说明(目的、前置条件、请求构造、执行步骤、预期结果、清理) + - 公共基础设施 + - 与其他场景的依赖关系 +3. 为每个测试例编写独立的 `TC-<序号>-<测试例名称>.md`,结构与现有 TC 文档保持一致。 +4. 更新 `bfe/tests/integration/测试设计文档/测试场景总体说明.md`: + - 在“场景清单”中新增一行; + - 在“场景与测试例对应关系”中新增该 scenario 的 TC 列表。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 6. 编写集成测试代码 + +BFE 的集成测试代码位于 `bfe/tests/integration/implementation/`。 + +1. 选择最接近的现有 scenario 作为模板(例如需要 Redis 的可参考 `scenario-SC07-ai-rate-limit-redis-key`)。 +2. 新建 scenario 目录,命名规范: + ``` + scenario-SC<序号>-<简短英文描述> + ``` +3. 复制模板后,修改 `testdata/` 中的: + - `server_data_conf/host_rule.data`:host + - `server_data_conf/route_rule.data`:cluster + - `cluster_conf/gslb.data`:cluster 名称 + - `mod_ai_route/ai_route.data`:apikey owner / route binding + - `mod_ai_token_auth/mod_ai_token_auth.conf`、`mod_ai_rate_limit/mod_ai_rate_limit.conf` 等 +4. 编写 `_test.go`,覆盖: + - 正常路径 + - 功能开关开启/关闭对比 + - 边界场景(单 Key、多 ClientKeyId、BFE 重启后状态保持、错误码/降级) +5. 运行集成测试: + ```bash + cd bfe + go test ./tests/integration/implementation/scenario-SC-<描述>/... -v + ``` + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 7. 回归验证 + +1. 运行本次新增 scenario 的测试。 +2. 运行与被改动模块相关的既有 scenario: + - 多 API-Key 轮换:`scenario-SC02-multi-api-key` + - Redis 限流:`scenario-SC07-ai-rate-limit-redis-key` +3. 运行相关单元测试: + ```bash + go test ./bfe_server/... ./bfe_modules/mod_ai_route/... ./bfe_modules/mod_ai_token_auth/... + ``` +4. 如有失败,优先修复;修复后再次回归,直到全部通过。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 8. 收尾与总结 + +1. 检查是否有注释/文档描述的是旧行为,及时同步更新。 +2. 向用户汇报: + - 改动了哪些文件 + - 新增/修改了哪些测试 + - 验证结果 + - 仍存在的风险或待决策点(如有) + +3. **Git 提交前必须人工确认**:如果用户要求或流程需要执行 `git commit`、`git push` 等 Git 操作,必须先向用户清晰说明本次提交内容(包含文件清单与主要变更摘要),并取得明确同意后再执行。禁止在未获授权的情况下自动提交或推送。获得授权后,`git push` 默认推送到 `origin`,不要推送到 `upstream`,除非用户明确指定。 + +## 常见陷阱 + +- **cluster 名称不一致**:`route_rule.data`、`gslb.data`、ai_route 中的 cluster 名称必须一致,否则 BFE 启动会报 `no backend conf`。 +- **ApikeyRouteTableBindings 遗漏**:新增 client apiKey 时,必须在 `mod_ai_route/ai_route.data` 的 `ApikeyRouteTableBindings` 中为其绑定路由表。 +- **Redis 绑定 key 格式**:确认代码中实际使用的 key 格式(如 `bfe:ai:key_affinity::`)。 +- **单 Key 优化**:如果实现了“单 Key 不走 Redis”,需要同时避免读和写,否则集成测试里 `Exists` 会误判。 +- **BFE 端口冲突**:集成测试里 `processEnv.StartBFE` 会自动分配端口,不要手动写死。 + +## 推荐命令速查 + +```bash +# 模块单元测试 +cd bfe +go test ./bfe_server/... ./bfe_modules/mod_ai_route/... ./bfe_modules/mod_ai_token_auth/... + +# 单个集成测试场景(详细日志) +go test ./tests/integration/implementation/scenario-SC-<描述>/... -v + +# 相关场景回归 +go test ./tests/integration/implementation/scenario-SC02-multi-api-key/... +go test ./tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/... +``` diff --git a/.trae/skills/bfe-rd-workflow/SKILL.md b/.trae/skills/bfe-rd-workflow/SKILL.md new file mode 100644 index 000000000..b0e852421 --- /dev/null +++ b/.trae/skills/bfe-rd-workflow/SKILL.md @@ -0,0 +1,198 @@ +--- +name: bfe-rd-workflow +description: 引导用户在 bfe 代码库中完成一次完整的功能研发流程,包括需求对齐、文档修改、代码实现、集成测试与回归验证。 +--- + +# BFE 代码库研发流程 + +本 Skill 适用于在 `bfe/` 目录中新增或修改 BFE 功能(例如 AI Gateway 的多 API-Key 会话亲和性、限流、计费、路由、协议支持等)的完整研发流程。 + +## 触发语 + +当用户提出以下类型请求时启用本流程: + +- “我要实现 xxx 功能” +- “请帮我完成 xxx 的代码与测试” +- “请在 BFE 中增加/修改 xxx” + +## 执行原则 + +1. **分阶段暂停确认**:本流程将研发拆分为多个 Phase。每个 Phase 执行完毕后,必须暂停并等待用户确认“可以继续”后,再进入下一个 Phase。不要在未获得用户确认的情况下自动推进到下一阶段。 +2. **Git 提交前确认**:在任何 `git commit`、`git push` 或其他会改变 Git 仓库状态的操作之前,必须先向用户说明变更内容并取得明确同意。禁止自动执行 Git 提交或推送。 +3. **Git push 默认目标为 origin**:如果获得用户授权执行 `git push`,默认推送到 `origin` 远程仓库,而不是 `upstream`。除非用户明确指定其他 remote,否则不使用 `upstream`。 + +## 研发阶段 + +### Phase 0. 需求澄清与范围界定 + +1. 让用户明确: + - 功能目标(一句话描述) + - 验收标准(必须通过的测试/行为) + - 影响范围(是否改配置协议、是否改 Redis/状态、是否影响既有请求路径) +2. 判断是否为“非平凡改动”(多文件、有架构选择、用户偏好影响实现): + - 是 → 调用 `EnterPlanMode`,先出设计文档/plan 再执行。 + - 否 → 直接进入 Phase 1。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 1. 修改 modifications 文档 + +BFE 侧的改动必须在 `bfe/docs/zh_cn/modifications/` 留下修改说明,便于后续维护与审计。 + +1. 在 `bfe/docs/zh_cn/modifications/` 下新建日期化目录,例如: + ``` + bfe/docs/zh_cn/modifications/2026-08-26-ai-key-session-affinity/ + ``` +2. 在该目录下创建 `design-changes.md`,包含: + - 背景与目标 + - 主要改动点(数据结构、模块行为、新增字段) + - 配置影响 + - 兼容性说明 +3. 如已有同主题目录,则更新其中的文档,不要重复创建。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 2. 更新 configuration 文档 + +如果改动涉及配置文件(`cluster_conf.data`、`mod_*.conf`、`bfe.conf` 等),必须同步更新中英文配置文档: + +1. 中文:`bfe/docs/zh_cn/configuration/` +2. 英文:`bfe/docs/en_us/configuration/` + +更新原则: + +- 新增字段要说明类型、默认值、是否必填、示例。 +- 修改字段要说明前后行为差异。 +- 两个语言目录尽量保持内容一致。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 3. 更新 sys_design 文档 + +如果改动涉及系统设计、协议交互或状态存储,需要更新 `bfe/docs/zh_cn/sys_design/`: + +1. 查找现有相关文档(例如 `multi_api_key.md`)。 +2. 在文档中新增/修改对应章节,说明: + - 方案选型 + - 数据流 + - 状态存储(如 Redis key 格式、TTL) + - 边界情况与优化 +3. 如需新增独立文档,直接创建 `.md` 文件,并在相关文档中建立链接。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 4. 代码实现 + +1. 阅读相关源码,确认: + - 配置结构体(`bfe_config/bfe_cluster_conf/cluster_conf/`) + - 模块加载与处理逻辑(`bfe_modules/mod_*`) + - 反向代理与 Key 选择逻辑(`bfe_server/reverseproxy.go` 及相关测试) +2. 做最小改动,优先匹配现有代码风格: + - 不引入未使用的依赖。 + - 不修改与本次需求无关的逻辑。 +3. 关键实现完成后,先跑模块级单元测试: + ```bash + cd bfe + go test ./bfe_server/... ./bfe_modules/<相关模块>/... + ``` + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 5. 编写集成测试设计文档 + +BFE 的集成测试设计文档位于 `bfe/tests/integration/测试设计文档/`。在写测试代码之前,先补充对应场景的设计文档,便于后续维护与评审。 + +1. 在 `bfe/tests/integration/测试设计文档/` 下新建 scenario 目录,命名规范: + ``` + scenario-SC<序号>-<简短中文描述> + ``` +2. 编写 `场景说明.md`,包含: + - 场景背景与目的 + - 运行模式 + - 涉及的 BFE 配置文件 + - 测试 Cluster 与关键配置(AIConf、KeyPolicy、路由表等) + - 测试例列表 + - 每个测试例的详细说明(目的、前置条件、请求构造、执行步骤、预期结果、清理) + - 公共基础设施 + - 与其他场景的依赖关系 +3. 为每个测试例编写独立的 `TC-<序号>-<测试例名称>.md`,结构与现有 TC 文档保持一致。 +4. 更新 `bfe/tests/integration/测试设计文档/测试场景总体说明.md`: + - 在“场景清单”中新增一行; + - 在“场景与测试例对应关系”中新增该 scenario 的 TC 列表。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 6. 编写集成测试代码 + +BFE 的集成测试代码位于 `bfe/tests/integration/implementation/`。 + +1. 选择最接近的现有 scenario 作为模板(例如需要 Redis 的可参考 `scenario-SC07-ai-rate-limit-redis-key`)。 +2. 新建 scenario 目录,命名规范: + ``` + scenario-SC<序号>-<简短英文描述> + ``` +3. 复制模板后,修改 `testdata/` 中的: + - `server_data_conf/host_rule.data`:host + - `server_data_conf/route_rule.data`:cluster + - `cluster_conf/gslb.data`:cluster 名称 + - `mod_ai_route/ai_route.data`:apikey owner / route binding + - `mod_ai_token_auth/mod_ai_token_auth.conf`、`mod_ai_rate_limit/mod_ai_rate_limit.conf` 等 +4. 编写 `_test.go`,覆盖: + - 正常路径 + - 功能开关开启/关闭对比 + - 边界场景(单 Key、多 ClientKeyId、BFE 重启后状态保持、错误码/降级) +5. 运行集成测试: + ```bash + cd bfe + go test ./tests/integration/implementation/scenario-SC-<描述>/... -v + ``` + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 7. 回归验证 + +1. 运行本次新增 scenario 的测试。 +2. 运行与被改动模块相关的既有 scenario: + - 多 API-Key 轮换:`scenario-SC02-multi-api-key` + - Redis 限流:`scenario-SC07-ai-rate-limit-redis-key` +3. 运行相关单元测试: + ```bash + go test ./bfe_server/... ./bfe_modules/mod_ai_route/... ./bfe_modules/mod_ai_token_auth/... + ``` +4. 如有失败,优先修复;修复后再次回归,直到全部通过。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 8. 收尾与总结 + +1. 检查是否有注释/文档描述的是旧行为,及时同步更新。 +2. 向用户汇报: + - 改动了哪些文件 + - 新增/修改了哪些测试 + - 验证结果 + - 仍存在的风险或待决策点(如有) + +3. **Git 提交前必须人工确认**:如果用户要求或流程需要执行 `git commit`、`git push` 等 Git 操作,必须先向用户清晰说明本次提交内容(包含文件清单与主要变更摘要),并取得明确同意后再执行。禁止在未获授权的情况下自动提交或推送。获得授权后,`git push` 默认推送到 `origin`,不要推送到 `upstream`,除非用户明确指定。 + +## 常见陷阱 + +- **cluster 名称不一致**:`route_rule.data`、`gslb.data`、ai_route 中的 cluster 名称必须一致,否则 BFE 启动会报 `no backend conf`。 +- **ApikeyRouteTableBindings 遗漏**:新增 client apiKey 时,必须在 `mod_ai_route/ai_route.data` 的 `ApikeyRouteTableBindings` 中为其绑定路由表。 +- **Redis 绑定 key 格式**:确认代码中实际使用的 key 格式(如 `bfe:ai:key_affinity::`)。 +- **单 Key 优化**:如果实现了“单 Key 不走 Redis”,需要同时避免读和写,否则集成测试里 `Exists` 会误判。 +- **BFE 端口冲突**:集成测试里 `processEnv.StartBFE` 会自动分配端口,不要手动写死。 + +## 推荐命令速查 + +```bash +# 模块单元测试 +cd bfe +go test ./bfe_server/... ./bfe_modules/mod_ai_route/... ./bfe_modules/mod_ai_token_auth/... + +# 单个集成测试场景(详细日志) +go test ./tests/integration/implementation/scenario-SC-<描述>/... -v + +# 相关场景回归 +go test ./tests/integration/implementation/scenario-SC02-multi-api-key/... +go test ./tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/... +``` diff --git a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go index 799e8c30e..42cddfa97 100644 --- a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go +++ b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go @@ -142,6 +142,12 @@ type AIKeyPolicy struct { MaxRetries int // total retry budget within one aiClusterInvoke call RetryBackoffInitial int // ms RetryBackoffMax int // ms + + // Session-level API-Key affinity based on Redis + ClientKeyId + SessionAffinity bool // default false + SessionAffinityTTL int // Redis binding TTL in seconds, default 300 + SessionAffinityRedisPrefix string // Redis key prefix, default "bfe:ai:key_affinity" + SessionAffinityPenaltyEnable bool // skip Keys recently returned 429/401/403, default true } // TimeRange defines a single time range within a week for a pricing tier. @@ -831,6 +837,35 @@ func AIConfCheck(conf *AIConf) error { } } + if conf.KeyPolicy != nil { + if err := AIKeyPolicyCheck(conf.KeyPolicy); err != nil { + return fmt.Errorf("KeyPolicy:%s", err.Error()) + } + } + + return nil +} + +// AIKeyPolicyCheck checks and fills defaults for AIKeyPolicy. +func AIKeyPolicyCheck(policy *AIKeyPolicy) error { + if policy.SessionAffinityTTL < 0 { + return fmt.Errorf("SessionAffinityTTL must be >= 0") + } + if policy.SessionAffinityTTL == 0 { + policy.SessionAffinityTTL = 300 + } + if policy.SessionAffinityRedisPrefix == "" { + policy.SessionAffinityRedisPrefix = "bfe:ai:key_affinity" + } + if policy.Strategy == "" { + policy.Strategy = "weighted_random" + } + if policy.RetryBackoffInitial < 0 { + return fmt.Errorf("RetryBackoffInitial must be >= 0") + } + if policy.RetryBackoffMax < 0 { + return fmt.Errorf("RetryBackoffMax must be >= 0") + } return nil } diff --git a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go index 446c0fcb2..548c73afc 100644 --- a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go +++ b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go @@ -293,6 +293,36 @@ func TestAIConfCheck(t *testing.T) { t.Errorf("unexpected error: %v", err) } }) + + t.Run("key policy affinity defaults", func(t *testing.T) { + conf := &AIConf{ + KeyPolicy: &AIKeyPolicy{ + Strategy: "weighted_random", + MaxRetries: 3, + SessionAffinity: true, + }, + } + if err := AIConfCheck(conf); err != nil { + t.Errorf("unexpected error: %v", err) + } + if conf.KeyPolicy.SessionAffinityTTL != 300 { + t.Errorf("expected SessionAffinityTTL default 300, got %d", conf.KeyPolicy.SessionAffinityTTL) + } + if conf.KeyPolicy.SessionAffinityRedisPrefix != "bfe:ai:key_affinity" { + t.Errorf("expected default redis prefix, got %s", conf.KeyPolicy.SessionAffinityRedisPrefix) + } + }) + + t.Run("key policy negative ttl error", func(t *testing.T) { + conf := &AIConf{ + KeyPolicy: &AIKeyPolicy{ + SessionAffinityTTL: -1, + }, + } + if err := AIConfCheck(conf); err == nil { + t.Error("expected error when SessionAffinityTTL < 0") + } + }) } func TestModelTableCheck_Tiers(t *testing.T) { diff --git a/bfe_modules/mod_ai_rate_limit/mod_ai_rate_limit.go b/bfe_modules/mod_ai_rate_limit/mod_ai_rate_limit.go index 38e1930d3..b4e1899c1 100644 --- a/bfe_modules/mod_ai_rate_limit/mod_ai_rate_limit.go +++ b/bfe_modules/mod_ai_rate_limit/mod_ai_rate_limit.go @@ -78,6 +78,10 @@ type ModuleAiRateLimit struct { isRejectOnRedisError bool } +func (m *ModuleAiRateLimit) RedisClient() redis_client.Client { + return m.redisClient +} + func NewModuleAiRateLimit() *ModuleAiRateLimit { m := new(ModuleAiRateLimit) m.name = ModAiRateLimit diff --git a/bfe_server/proxy_state.go b/bfe_server/proxy_state.go index 20761483d..b805a365d 100644 --- a/bfe_server/proxy_state.go +++ b/bfe_server/proxy_state.go @@ -123,6 +123,13 @@ type ProxyState struct { // sse SseReqServed *metrics.Counter SseReqActive *metrics.Gauge + + // ai key affinity + ReqAiKeyAffinityHit *metrics.Counter + ReqAiKeyAffinityMiss *metrics.Counter + ReqAiKeyAffinityRebind *metrics.Counter + ReqAiKeyAffinityPenaltySkip *metrics.Counter + ReqAiKeyAffinityRedisErr *metrics.Counter } func (s *ProxyState) ClientConnServedInc(proto string, value uint) { diff --git a/bfe_server/reverseproxy.go b/bfe_server/reverseproxy.go index c80360303..6cf7710da 100644 --- a/bfe_server/reverseproxy.go +++ b/bfe_server/reverseproxy.go @@ -21,7 +21,9 @@ package bfe_server import ( + "crypto/sha256" "crypto/tls" + "encoding/hex" "errors" "fmt" "io" @@ -47,6 +49,7 @@ import ( "github.com/bfenetworks/bfe/bfe_http" "github.com/bfenetworks/bfe/bfe_http2" "github.com/bfenetworks/bfe/bfe_module" + "github.com/bfenetworks/bfe/bfe_modules/mod_ai_rate_limit" "github.com/bfenetworks/bfe/bfe_modules/mod_ai_token_auth" "github.com/bfenetworks/bfe/bfe_modules/mod_body_process" "github.com/bfenetworks/bfe/bfe_route" @@ -54,6 +57,7 @@ import ( "github.com/bfenetworks/bfe/bfe_spdy" "github.com/bfenetworks/bfe/bfe_util" "github.com/bfenetworks/bfe/bfe_util/epp" + "github.com/bfenetworks/bfe/bfe_util/redis_client" ) // TrailerPrefix is a magic prefix for ResponseWriter.Header map keys @@ -1639,6 +1643,18 @@ func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.Ser state := newAIKeyAttemptState() + // session-level key affinity + var redisClient redis_client.Client + if policy.SessionAffinity && srv.Modules != nil { + if module := srv.Modules.GetModule(mod_ai_rate_limit.ModAiRateLimit); module != nil { + if m, ok := module.(*mod_ai_rate_limit.ModuleAiRateLimit); ok { + redisClient = m.RedisClient() + } + } + } + sessionID := clientKeySessionID(basicReq) + boundName := "" + var lastErr error var idx int var key cluster_conf.AIKey @@ -1660,7 +1676,7 @@ func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.Ser } if !keepKey { - idx, key, ok = chooseNextAIKey(keys, state) + idx, key, boundName, ok = chooseAIKeyWithAffinity(cluster.Name, keys, policy, state, redisClient, sessionID, p.proxyState) if !ok { log.Logger.Warn("aiClusterInvoke: all ai keys exhausted for cluster[%s]", attempt.ClusterName) break @@ -1681,6 +1697,14 @@ func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.Ser // success: stop key-level retry if err == nil && statusCode < 400 { + if policy.SessionAffinity && redisClient != nil && sessionID != "" && key.Name != boundName { + if err := redisSetBinding(cluster.Name, sessionID, key.Name, policy, redisClient); err != nil { + log.Logger.Warn("aiClusterInvoke: redis set binding error[%v]", err) + p.proxyState.ReqAiKeyAffinityRedisErr.Inc(1) + } else { + p.proxyState.ReqAiKeyAffinityRebind.Inc(1) + } + } return res, action, cluster, nil, newBodyModel } @@ -1689,10 +1713,22 @@ func (p *ReverseProxy) aiClusterInvoke(srv *BfeServer, serverConf *bfe_route.Ser case statusCode == 429: // rate limit: mark key as used and rotate to another key state.usedSet[idx] = struct{}{} + if policy.SessionAffinity && policy.SessionAffinityPenaltyEnable && redisClient != nil { + setKeyPenalty(cluster.Name, key.Name, "429", 60, policy, redisClient) + if sessionID != "" { + redisDeleteBinding(cluster.Name, sessionID, policy, redisClient) + } + } log.Logger.Info("aiClusterInvoke: ai key [name=%s] rate limited (429), rotate", key.Name) case statusCode == 401 || statusCode == 402 || statusCode == 403: // auth failure: mark key as dead state.deadSet[idx] = struct{}{} + if policy.SessionAffinity && policy.SessionAffinityPenaltyEnable && redisClient != nil { + setKeyPenalty(cluster.Name, key.Name, fmt.Sprintf("%d", statusCode), 3600, policy, redisClient) + if sessionID != "" { + redisDeleteBinding(cluster.Name, sessionID, policy, redisClient) + } + } log.Logger.Info("aiClusterInvoke: ai key [name=%s] auth failed (%d), dead", key.Name, statusCode) case statusCode >= 500 || err != nil: // transient server failure or connection error: @@ -1914,11 +1950,193 @@ func calcBackoff(initial, max, attempt int) time.Duration { // defaultAIKeyPolicy returns the default key policy. func defaultAIKeyPolicy() cluster_conf.AIKeyPolicy { return cluster_conf.AIKeyPolicy{ - Strategy: "weighted_random", - MaxRetries: 0, - RetryBackoffInitial: 500, - RetryBackoffMax: 5000, + Strategy: "weighted_random", + MaxRetries: 0, + RetryBackoffInitial: 500, + RetryBackoffMax: 5000, + SessionAffinity: false, + SessionAffinityTTL: 300, + SessionAffinityRedisPrefix: "bfe:ai:key_affinity", + SessionAffinityPenaltyEnable: true, + } +} + +// clientKeySessionID returns the session identifier for API-Key affinity. +// It prefers the stable ClientKeyId; if absent, falls back to a hash of the +// raw API-Key value. +func clientKeySessionID(basicReq *bfe_basic.Request) string { + if ai := basicReq.GetAiBasicInfo(); ai != nil && ai.ClientKeyId != "" { + return ai.ClientKeyId + } + key := bfe_basic.GetApiKey(basicReq) + if key == "" { + return "" + } + h := sha256.Sum256([]byte(key)) + return hex.EncodeToString(h[:]) +} + +// aiKeyAffinityRedisKey returns the Redis key for session->key binding. +func aiKeyAffinityRedisKey(clusterName, sessionID, prefix string) string { + return fmt.Sprintf("%s:%s:%s", prefix, clusterName, sessionID) +} + +// aiKeyAffinityPenaltyRedisKey returns the Redis key for key penalty. +func aiKeyAffinityPenaltyRedisKey(clusterName, keyName, prefix string) string { + return fmt.Sprintf("%s:penalty:%s:%s", prefix, clusterName, keyName) +} + +func redisGetBinding(clusterName, sessionID string, policy cluster_conf.AIKeyPolicy, client redis_client.Client) (string, error) { + key := aiKeyAffinityRedisKey(clusterName, sessionID, policy.SessionAffinityRedisPrefix) + val, err := client.Get(key) + if err != nil { + return "", err + } + if val == nil { + return "", nil + } + b, ok := val.([]byte) + if !ok { + return "", fmt.Errorf("unexpected redis value type %T", val) + } + return string(b), nil +} + +func redisSetBinding(clusterName, sessionID, keyName string, policy cluster_conf.AIKeyPolicy, client redis_client.Client) error { + key := aiKeyAffinityRedisKey(clusterName, sessionID, policy.SessionAffinityRedisPrefix) + return client.Setex(key, []byte(keyName), policy.SessionAffinityTTL) +} + +func redisDeleteBinding(clusterName, sessionID string, policy cluster_conf.AIKeyPolicy, client redis_client.Client) error { + key := aiKeyAffinityRedisKey(clusterName, sessionID, policy.SessionAffinityRedisPrefix) + return client.Delete(key) +} + +func isKeyPenalized(clusterName, keyName string, policy cluster_conf.AIKeyPolicy, client redis_client.Client) bool { + key := aiKeyAffinityPenaltyRedisKey(clusterName, keyName, policy.SessionAffinityRedisPrefix) + val, err := client.Get(key) + if err != nil { + return false + } + return val != nil +} + +func setKeyPenalty(clusterName, keyName, reason string, ttl int, policy cluster_conf.AIKeyPolicy, client redis_client.Client) { + key := aiKeyAffinityPenaltyRedisKey(clusterName, keyName, policy.SessionAffinityRedisPrefix) + if err := client.Setex(key, []byte(reason), ttl); err != nil { + log.Logger.Warn("aiKeyAffinity: set penalty error[%v]", err) + } +} + +func filterPenaltyKeys(clusterName string, keys []cluster_conf.AIKey, policy cluster_conf.AIKeyPolicy, client redis_client.Client) ([]cluster_conf.AIKey, int) { + var filtered []cluster_conf.AIKey + skipped := 0 + for _, k := range keys { + if !isKeyPenalized(clusterName, k.Name, policy, client) { + filtered = append(filtered, k) + } else { + skipped++ + } + } + if len(filtered) == 0 { + // all keys are penalized; fallback to original list to avoid no key + return keys, skipped + } + return filtered, skipped +} + +func findKeyIndexByName(name string, keys []cluster_conf.AIKey) int { + for i, k := range keys { + if k.Name == name { + return i + } + } + return -1 +} + +func isKeyAlive(idx int, keys []cluster_conf.AIKey, state *aiKeyAttemptState) bool { + if idx < 0 || idx >= len(keys) { + return false + } + if keys[idx].Weight == 0 { + return false + } + if _, dead := state.deadSet[idx]; dead { + return false + } + return true +} + +// chooseAIKeyWithAffinity selects the next eligible AI key, optionally using +// Redis to maintain a session-level binding from ClientKeyId to key name. +// It returns the selected key index, the key, the bound key name (if any), +// and whether a key was successfully selected. +func chooseAIKeyWithAffinity( + clusterName string, + keys []cluster_conf.AIKey, + policy cluster_conf.AIKeyPolicy, + state *aiKeyAttemptState, + client redis_client.Client, + sessionID string, + proxyState *ProxyState, +) (int, cluster_conf.AIKey, string, bool) { + + if !policy.SessionAffinity || client == nil || sessionID == "" { + idx, key, ok := chooseNextAIKey(keys, state) + return idx, key, "", ok + } + + // short-circuit: single valid key needs no Redis lookup/write. + // Treat it as already bound so the success path does not rewrite Redis. + if len(keys) == 1 && keys[0].Weight > 0 { + return 0, keys[0], keys[0].Name, true + } + + candidateKeys := keys + penaltySkipCount := 0 + if policy.SessionAffinityPenaltyEnable { + candidateKeys, penaltySkipCount = filterPenaltyKeys(clusterName, keys, policy, client) + } + + boundName, err := redisGetBinding(clusterName, sessionID, policy, client) + if err != nil { + log.Logger.Warn("aiKeyAffinity: redis get binding error[%v], fallback to random", err) + if proxyState != nil { + proxyState.ReqAiKeyAffinityRedisErr.Inc(1) + } + idx, key, ok := chooseNextAIKey(candidateKeys, state) + return idx, key, "", ok + } + + if boundName != "" { + idx := findKeyIndexByName(boundName, keys) + if idx >= 0 && isKeyAlive(idx, keys, state) && !isKeyPenalized(clusterName, boundName, policy, client) { + if proxyState != nil { + proxyState.ReqAiKeyAffinityHit.Inc(1) + } + return idx, keys[idx], boundName, true + } + // binding stale (key removed/dead/penalized), continue to select a new key + } + + idx, key, ok := chooseNextAIKey(candidateKeys, state) + if ok { + if err := redisSetBinding(clusterName, sessionID, key.Name, policy, client); err != nil { + log.Logger.Warn("aiKeyAffinity: redis set binding error[%v]", err) + if proxyState != nil { + proxyState.ReqAiKeyAffinityRedisErr.Inc(1) + } + } + } + if proxyState != nil { + if boundName == "" { + proxyState.ReqAiKeyAffinityMiss.Inc(1) + } + if penaltySkipCount > 0 { + proxyState.ReqAiKeyAffinityPenaltySkip.Inc(uint(penaltySkipCount)) + } } + return idx, key, boundName, ok } // clusterSupportsAuthStyle checks whether the cluster provider supports the diff --git a/bfe_server/reverseproxy_ai_test.go b/bfe_server/reverseproxy_ai_test.go index e79274bc1..1aeea47b6 100644 --- a/bfe_server/reverseproxy_ai_test.go +++ b/bfe_server/reverseproxy_ai_test.go @@ -15,12 +15,16 @@ package bfe_server import ( + "errors" "testing" "time" + "github.com/bfenetworks/go-lib/web-monitor/metrics" + "github.com/bfenetworks/bfe/bfe_basic" "github.com/bfenetworks/bfe/bfe_config/bfe_cluster_conf/cluster_conf" "github.com/bfenetworks/bfe/bfe_http" + "github.com/bfenetworks/bfe/bfe_util/redis_client" ) func TestSelectTargetSingle(t *testing.T) { @@ -381,3 +385,299 @@ func TestComputeTargetModelMappingNoMatch(t *testing.T) { t.Errorf("expected modelB unchanged, got %s", model) } } + +// mockRedisClient is a simple in-memory redis_client.Client for unit tests. +type mockRedisClient struct { + data map[string]struct { + value []byte + expire int + } + failGet bool + failSet bool +} + +func newMockRedisClient() *mockRedisClient { + return &mockRedisClient{ + data: make(map[string]struct { + value []byte + expire int + }), + } +} + +func (m *mockRedisClient) Setex(key string, value []byte, expire int) error { + if m.failSet { + return errors.New("redis setex failed") + } + m.data[key] = struct { + value []byte + expire int + }{value: value, expire: expire} + return nil +} + +func (m *mockRedisClient) Get(key string) (interface{}, error) { + if m.failGet { + return nil, errors.New("redis get failed") + } + v, ok := m.data[key] + if !ok { + return nil, nil + } + return v.value, nil +} + +func (m *mockRedisClient) Expire(key string, expire int) error { + if v, ok := m.data[key]; ok { + v.expire = expire + m.data[key] = v + } + return nil +} + +func (m *mockRedisClient) Incr(key string) (int64, error) { + panic("not implemented") +} + +func (m *mockRedisClient) IncrAndExpire(key string, expire int) (int64, error) { + panic("not implemented") +} + +func (m *mockRedisClient) Decr(key string) (int64, error) { + panic("not implemented") +} + +func (m *mockRedisClient) PIncr(keys []string) ([]int64, error) { + panic("not implemented") +} + +func (m *mockRedisClient) GetInt64(key string) (int64, error) { + panic("not implemented") +} + +func (m *mockRedisClient) IncrBy(key string, delta int64) (int64, error) { + panic("not implemented") +} + +func (m *mockRedisClient) Delete(key string) error { + delete(m.data, key) + return nil +} + +func (m *mockRedisClient) NewScript(src string) redis_client.RedisScript { + panic("not implemented") +} + +func TestDefaultAIKeyPolicyIncludesAffinityDefaults(t *testing.T) { + policy := defaultAIKeyPolicy() + if policy.SessionAffinity != false { + t.Errorf("expected SessionAffinity false, got %v", policy.SessionAffinity) + } + if policy.SessionAffinityTTL != 300 { + t.Errorf("expected SessionAffinityTTL 300, got %d", policy.SessionAffinityTTL) + } + if policy.SessionAffinityRedisPrefix != "bfe:ai:key_affinity" { + t.Errorf("expected SessionAffinityRedisPrefix bfe:ai:key_affinity, got %s", policy.SessionAffinityRedisPrefix) + } + if policy.SessionAffinityPenaltyEnable != true { + t.Errorf("expected SessionAffinityPenaltyEnable true, got %v", policy.SessionAffinityPenaltyEnable) + } +} + +func TestChooseAIKeyWithAffinity_SingleKey(t *testing.T) { + keys := []cluster_conf.AIKey{ + {Name: "key-a", Key: "ak-a", Weight: 100}, + } + policy := cluster_conf.AIKeyPolicy{ + Strategy: "weighted_random", + SessionAffinity: true, + SessionAffinityTTL: 300, + SessionAffinityRedisPrefix: "bfe:ai:key_affinity", + SessionAffinityPenaltyEnable: true, + } + state := newAIKeyAttemptState() + client := newMockRedisClient() + + idx, key, boundName, ok := chooseAIKeyWithAffinity("cluster", keys, policy, state, client, "session-1", nil) + if !ok { + t.Fatal("expected to select key") + } + if idx != 0 || key.Name != "key-a" { + t.Errorf("expected key-a, got %s", key.Name) + } + if boundName != "key-a" { + t.Errorf("expected bound name key-a for single key, got %s", boundName) + } + // single key should not touch redis + if len(client.data) != 0 { + t.Errorf("expected no redis access for single key, got %d entries", len(client.data)) + } +} + +func TestChooseAIKeyWithAffinity_Hit(t *testing.T) { + keys := []cluster_conf.AIKey{ + {Name: "key-a", Key: "ak-a", Weight: 50}, + {Name: "key-b", Key: "ak-b", Weight: 50}, + } + policy := cluster_conf.AIKeyPolicy{ + Strategy: "weighted_random", + SessionAffinity: true, + SessionAffinityTTL: 300, + SessionAffinityRedisPrefix: "bfe:ai:key_affinity", + SessionAffinityPenaltyEnable: true, + } + state := newAIKeyAttemptState() + client := newMockRedisClient() + client.Setex("bfe:ai:key_affinity:cluster:session-1", []byte("key-b"), 300) + + proxyState := &ProxyState{ + ReqAiKeyAffinityHit: new(metrics.Counter), + } + + _, key, boundName, ok := chooseAIKeyWithAffinity("cluster", keys, policy, state, client, "session-1", proxyState) + if !ok { + t.Fatal("expected to select key") + } + if key.Name != "key-b" { + t.Errorf("expected key-b from binding, got %s", key.Name) + } + if boundName != "key-b" { + t.Errorf("expected boundName key-b, got %s", boundName) + } + if proxyState.ReqAiKeyAffinityHit.Get() != 1 { + t.Errorf("expected affinity hit counter 1, got %d", proxyState.ReqAiKeyAffinityHit.Get()) + } +} + +func TestChooseAIKeyWithAffinity_Miss(t *testing.T) { + keys := []cluster_conf.AIKey{ + {Name: "key-a", Key: "ak-a", Weight: 50}, + {Name: "key-b", Key: "ak-b", Weight: 50}, + } + policy := cluster_conf.AIKeyPolicy{ + Strategy: "weighted_random", + SessionAffinity: true, + SessionAffinityTTL: 300, + SessionAffinityRedisPrefix: "bfe:ai:key_affinity", + SessionAffinityPenaltyEnable: true, + } + state := newAIKeyAttemptState() + client := newMockRedisClient() + + proxyState := &ProxyState{ + ReqAiKeyAffinityMiss: new(metrics.Counter), + } + + idx, _, boundName, ok := chooseAIKeyWithAffinity("cluster", keys, policy, state, client, "session-1", proxyState) + if !ok { + t.Fatal("expected to select key") + } + if idx < 0 || idx >= len(keys) { + t.Errorf("expected valid index, got %d", idx) + } + if boundName != "" { + t.Errorf("expected no previous binding, got %s", boundName) + } + if proxyState.ReqAiKeyAffinityMiss.Get() != 1 { + t.Errorf("expected affinity miss counter 1, got %d", proxyState.ReqAiKeyAffinityMiss.Get()) + } + // binding should be written + if _, exists := client.data["bfe:ai:key_affinity:cluster:session-1"]; !exists { + t.Error("expected binding to be written to redis") + } +} + +func TestChooseAIKeyWithAffinity_RedisErrFallback(t *testing.T) { + keys := []cluster_conf.AIKey{ + {Name: "key-a", Key: "ak-a", Weight: 50}, + {Name: "key-b", Key: "ak-b", Weight: 50}, + } + policy := cluster_conf.AIKeyPolicy{ + Strategy: "weighted_random", + SessionAffinity: true, + SessionAffinityTTL: 300, + SessionAffinityRedisPrefix: "bfe:ai:key_affinity", + SessionAffinityPenaltyEnable: true, + } + state := newAIKeyAttemptState() + client := newMockRedisClient() + client.failGet = true + + proxyState := &ProxyState{ + ReqAiKeyAffinityRedisErr: new(metrics.Counter), + } + + idx, _, _, ok := chooseAIKeyWithAffinity("cluster", keys, policy, state, client, "session-1", proxyState) + if !ok { + t.Fatal("expected fallback to random") + } + if idx < 0 || idx >= len(keys) { + t.Errorf("expected valid index, got %d", idx) + } + if proxyState.ReqAiKeyAffinityRedisErr.Get() != 1 { + t.Errorf("expected redis err counter 1, got %d", proxyState.ReqAiKeyAffinityRedisErr.Get()) + } +} + +func TestChooseAIKeyWithAffinity_PenaltySkip(t *testing.T) { + keys := []cluster_conf.AIKey{ + {Name: "key-a", Key: "ak-a", Weight: 50}, + {Name: "key-b", Key: "ak-b", Weight: 50}, + } + policy := cluster_conf.AIKeyPolicy{ + Strategy: "weighted_random", + SessionAffinity: true, + SessionAffinityTTL: 300, + SessionAffinityRedisPrefix: "bfe:ai:key_affinity", + SessionAffinityPenaltyEnable: true, + } + state := newAIKeyAttemptState() + client := newMockRedisClient() + client.Setex("bfe:ai:key_affinity:penalty:cluster:key-a", []byte("429"), 60) + + proxyState := &ProxyState{ + ReqAiKeyAffinityPenaltySkip: new(metrics.Counter), + } + + // force deterministic selection: only key-b is eligible + _, key, _, ok := chooseAIKeyWithAffinity("cluster", keys, policy, state, client, "session-1", proxyState) + if !ok { + t.Fatal("expected to select key") + } + if key.Name != "key-b" { + t.Errorf("expected key-b after penalty skip, got %s", key.Name) + } + if proxyState.ReqAiKeyAffinityPenaltySkip.Get() != 1 { + t.Errorf("expected penalty skip counter 1, got %d", proxyState.ReqAiKeyAffinityPenaltySkip.Get()) + } +} + +func TestChooseAIKeyWithAffinity_Disabled(t *testing.T) { + keys := []cluster_conf.AIKey{ + {Name: "key-a", Key: "ak-a", Weight: 50}, + {Name: "key-b", Key: "ak-b", Weight: 50}, + } + policy := cluster_conf.AIKeyPolicy{ + Strategy: "weighted_random", + SessionAffinity: false, + SessionAffinityTTL: 300, + SessionAffinityRedisPrefix: "bfe:ai:key_affinity", + SessionAffinityPenaltyEnable: true, + } + state := newAIKeyAttemptState() + client := newMockRedisClient() + + idx, _, boundName, ok := chooseAIKeyWithAffinity("cluster", keys, policy, state, client, "session-1", nil) + if !ok { + t.Fatal("expected to select key") + } + if idx < 0 || idx >= len(keys) { + t.Errorf("expected valid index, got %d", idx) + } + if boundName != "" { + t.Errorf("expected no binding when disabled, got %s", boundName) + } + if len(client.data) != 0 { + t.Errorf("expected no redis access when disabled, got %d entries", len(client.data)) + } +} diff --git a/docs/en_us/configuration/mod_ai_rate_limit/mod_ai_rate_limit.conf.md b/docs/en_us/configuration/mod_ai_rate_limit/mod_ai_rate_limit.conf.md index 5b96dfd0c..806fe1d01 100644 --- a/docs/en_us/configuration/mod_ai_rate_limit/mod_ai_rate_limit.conf.md +++ b/docs/en_us/configuration/mod_ai_rate_limit/mod_ai_rate_limit.conf.md @@ -4,6 +4,8 @@ `mod_ai_rate_limit.conf` is the basic configuration file of the `mod_ai_rate_limit` module, used to specify the rate limit rule file path, Redis connection parameters, etc. +> Note: Since BFE introduced session-level API-Key affinity, the `Redis` configuration in this file is also reused as the storage backend for `AIConf.KeyPolicy.SessionAffinity`. If a cluster enables `SessionAffinity` but no usable Redis is configured, the affinity capability automatically degrades to weighted random selection. + ## Configuration Description | Configuration Item | Type | Meaning | Required | Supplementary Description | Validity Condition | diff --git a/docs/en_us/configuration/server_data_conf/cluster_conf.data.md b/docs/en_us/configuration/server_data_conf/cluster_conf.data.md index d30415066..02954ac92 100644 --- a/docs/en_us/configuration/server_data_conf/cluster_conf.data.md +++ b/docs/en_us/configuration/server_data_conf/cluster_conf.data.md @@ -116,6 +116,18 @@ Note: The following configuration items are located in the namespace `Config[v]` | AIConf.KeyPolicy.MaxRetries | Integer | Total additional retry count | N | Maximum retry count excluding the first selection in one `aiClusterInvoke` call; `0` means no retry | >= 0 | | AIConf.KeyPolicy.RetryBackoffInitial | Integer | Initial backoff time, in milliseconds | N | Backoff time for the first retry | >= 0 | | AIConf.KeyPolicy.RetryBackoffMax | Integer | Maximum backoff time, in milliseconds | N | Upper limit of backoff time | >= 0, and must be >= RetryBackoffInitial | +| AIConf.KeyPolicy.SessionAffinity | Boolean | Whether to enable session-level API-Key affinity based on Redis + `ClientKeyId` | N | Default `false`; when enabled, requests from the same `ClientKeyId` are bound to the same API-Key | - | +| AIConf.KeyPolicy.SessionAffinityTTL | Integer | TTL of the `ClientKeyId -> KeyName` binding in Redis, in seconds | N | Default `300` | >= 0 | +| AIConf.KeyPolicy.SessionAffinityRedisPrefix | String | Prefix of the Redis binding key | N | Default `"bfe:ai:key_affinity"` | Non-empty | +| AIConf.KeyPolicy.SessionAffinityPenaltyEnable | Boolean | Whether to enable Key penalty: skip Keys that recently returned 429/401/403 | N | Default `true` | - | + +**Session-level Key Affinity Notes:** + +- When enabled, BFE uses `AiBasicInfo.ClientKeyId` as the session identifier and maintains a binding `{prefix}:{cluster_name}:{client_key_id} -> ` in Redis. +- Subsequent requests with the same `ClientKeyId` prefer the bound Key, which improves the upstream provider's prompt cache hit rate (especially when multiple Keys belong to different provider accounts). +- If the bound Key is penalized, deleted, or has weight `0`, a new Key is selected and the binding is updated. +- If Redis is unavailable, the affinity logic gracefully degrades to weighted random selection without affecting request success rate. +- If `Keys` contains only one valid Key, it is returned directly without accessing Redis. ##### AIConf.ModelTable elements @@ -316,7 +328,11 @@ Note: The following configuration items are located in the namespace `Config[v]` "Strategy": "weighted_random", "MaxRetries": 3, "RetryBackoffInitial": 500, - "RetryBackoffMax": 5000 + "RetryBackoffMax": 5000, + "SessionAffinity": true, + "SessionAffinityTTL": 300, + "SessionAffinityRedisPrefix": "bfe:ai:key_affinity", + "SessionAffinityPenaltyEnable": true }, "ModelMapping": { "gpt-4": "backend-gpt-4-model" diff --git a/docs/zh_cn/configuration/mod_ai_rate_limit/mod_ai_rate_limit.conf.md b/docs/zh_cn/configuration/mod_ai_rate_limit/mod_ai_rate_limit.conf.md index 7865e521c..20f74d64f 100644 --- a/docs/zh_cn/configuration/mod_ai_rate_limit/mod_ai_rate_limit.conf.md +++ b/docs/zh_cn/configuration/mod_ai_rate_limit/mod_ai_rate_limit.conf.md @@ -4,6 +4,8 @@ `mod_ai_rate_limit.conf` 是 `mod_ai_rate_limit` 模块的基础配置文件,用于指定限流规则文件路径、Redis 连接参数等。 +> 说明:自 BFE 支持 AI Key 会话级亲和性后,本文件中的 `Redis` 配置也会被复用为 `AIConf.KeyPolicy.SessionAffinity` 的存储后端。若集群开启 `SessionAffinity` 但未配置可用的 Redis,亲和能力将自动降级为加权随机选择。 + ## 配置描述 | 配置项 | 类型 | 参数含义 | 必填 | 补充描述 | 合法性条件 | diff --git a/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md b/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md index dedd510c7..ca9232a66 100644 --- a/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md +++ b/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md @@ -258,7 +258,11 @@ "Strategy": "weighted_random", "MaxRetries": 3, "RetryBackoffInitial": 500, - "RetryBackoffMax": 5000 + "RetryBackoffMax": 5000, + "SessionAffinity": true, + "SessionAffinityTTL": 300, + "SessionAffinityRedisPrefix": "bfe:ai:key_affinity", + "SessionAffinityPenaltyEnable": true } ``` @@ -268,6 +272,18 @@ | MaxRetries | integer | N | 一次 `aiClusterInvoke` 调用内,除首次选择外的最大重试次数;`0` 表示不重试 | >= 0 | | RetryBackoffInitial | integer | N | 首次重试的退避时间,单位毫秒 | >= 0 | | RetryBackoffMax | integer | N | 最大退避时间,单位毫秒 | >= 0,且须 >= `RetryBackoffInitial` | +| SessionAffinity | boolean | N | 是否开启基于 Redis + `ClientKeyId` 的会话级 Key 亲和性;默认 `false` | - | +| SessionAffinityTTL | integer | N | Redis 中 `ClientKeyId -> KeyName` 绑定的过期时间,单位秒;默认 `300` | >= 0 | +| SessionAffinityRedisPrefix | string | N | Redis 绑定键的前缀;默认 `"bfe:ai:key_affinity"` | 非空 | +| SessionAffinityPenaltyEnable | boolean | N | 是否开启 Key 惩罚:选择时跳过近期返回 429/401/403 的 Key;默认 `true` | - | + +**会话级 Key 亲和性说明:** + +- 开启后,BFE 使用 `AiBasicInfo.ClientKeyId` 作为会话标识,在 Redis 中维护 `{prefix}:{cluster_name}:{client_key_id} -> ` 的绑定。 +- 同一 `ClientKeyId` 的后续请求优先命中已绑定的 Key,从而提升 Provider 侧 prompt cache 命中率(适用于多 Key 来自不同 Provider 账户的场景)。 +- 当绑定 Key 被惩罚、被删除或权重为 `0` 时,自动重新选择并写入新绑定。 +- Redis 不可用时自动降级为无亲和的加权随机选择,不影响请求成功率。 +- 若 `Keys` 中只有一个有效 Key,直接短路返回,不会访问 Redis。 ### 9.3 AIConf.ModelTable 元素 @@ -526,7 +542,11 @@ "Strategy": "weighted_random", "MaxRetries": 3, "RetryBackoffInitial": 500, - "RetryBackoffMax": 5000 + "RetryBackoffMax": 5000, + "SessionAffinity": true, + "SessionAffinityTTL": 300, + "SessionAffinityRedisPrefix": "bfe:ai:key_affinity", + "SessionAffinityPenaltyEnable": true }, "ModelMapping": { "gpt-4": "backend-gpt-4-model" diff --git a/docs/zh_cn/modifications/2026-08-26-ai-key-session-affinity/design-changes.md b/docs/zh_cn/modifications/2026-08-26-ai-key-session-affinity/design-changes.md new file mode 100644 index 000000000..44e6a6fe2 --- /dev/null +++ b/docs/zh_cn/modifications/2026-08-26-ai-key-session-affinity/design-changes.md @@ -0,0 +1,323 @@ +# BFE 同 cluster 多 API-Key 会话级 Key 亲和性 + +## 1. 背景 + +BFE AI 网关已支持在一个 cluster 下配置多个 Provider API-Key,并在 `aiClusterInvoke()` 内按加权随机选择 Key,失败时在同请求内做 Key 级重试(详见 `bfe/docs/zh_cn/sys_design/multi_api_key.md`)。 + +当多个 API-Key 来自**不同 Provider 账户/组织**时,同一 agent 会话内的连续请求如果被轮流打到不同账户,每个账户只能看到本账户的 prompt cache,导致上游缓存命中率下降。因此需要在 BFE 侧实现**会话级 API-Key 亲和性**:让同一个客户会话的多个请求尽量落到同一个 Provider Key 上。 + +## 2. 需求澄清 + +1. 会话识别不依赖客户端额外传递 header/cookie: + - 主流 LLM SDK(OpenAI、Anthropic、DeepSeek 等)默认不会发送 `X-Session-ID` 之类的 header; + - HTTP keep-alive / TCP 连接在现代客户端下无法稳定对应一个逻辑会话; + - 因此采用 BFE 内部已有的 `AiBasicInfo.ClientKeyId` 作为会话标识。 +2. 亲和性只在 cluster 配置多个有效 Key 时生效;单 Key 场景直接短路,不增加 Redis 开销。 +3. 保持现有高可用语义:亲和 Key 出现 429 / 401 / 403 / 5xx 时,当前请求仍可重试/切换到其他 Key。 +4. 对未开启亲和性的集群完全向后兼容;Redis 故障时自动降级为加权随机。 + +## 3. 当前现状 + +| 层级 | 当前能力 | 不足 | +|---|---|---| +| Key 选择 | `aiClusterInvoke()` 调用 `chooseNextAIKey()` 做加权随机 | 同一客户多次请求可能命中不同 Key | +| 失败重试 | 同请求内 Key 级重试,标记 `used`/`dead` | 没有跨请求的状态绑定 | +| 会话标识 | `AiBasicInfo.ClientKeyId` 已存在且稳定 | 未用于 Key 选择 | +| Redis 连接 | `mod_ai_rate_limit` 已初始化 Redis 客户端 | 未在 Key 选择链路复用 | +| 配置 | `AIKeyPolicy` 只有 `Strategy`、`MaxRetries` 等 | 缺少亲和性相关开关 | + +## 4. 变更目标 + +1. 在 `AIKeyPolicy` 中新增会话级 Key 亲和性配置项。 +2. 在 `aiClusterInvoke()` 中引入 `chooseAIKeyWithAffinity()`,优先从 Redis 读取 `ClientKeyId` 的绑定 Key;未命中时按加权随机选择并写入 Redis。 +3. 增加 Key 惩罚机制:对近期返回 429/401/403 的 Key 写短/长 TTL 惩罚键,选择时跳过。 +4. 失败处理时支持重新绑定:当前请求切换 Key 成功后,更新 Redis 绑定。 +5. 新增监控指标,用于观察亲和命中率、重新绑定次数、Redis 错误次数等。 +6. 补充单元测试与集成测试。 + +## 5. 变更总览 + +| 模块 | 主要改动 | +|---|---| +| `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` | `AIKeyPolicy` 新增 `SessionAffinity`、`SessionAffinityTTL`、`SessionAffinityRedisPrefix`、`SessionAffinityPenaltyEnable` 字段 | +| `bfe/bfe_server/reverseproxy.go` | 新增 `chooseAIKeyWithAffinity`、`clientKeySessionID`、Redis 绑定读写/删除、惩罚键读写/过滤等 helper;修改 `aiClusterInvoke()` 选择入口;在失败分支增加惩罚与重新绑定逻辑 | +| `bfe/bfe_server/reverseproxy.go` | 新增亲和性监控指标计数 | +| 控制面文档 | 更新 `ai-gateway-api/design-docs/api-define/OpenAPI接口定义/clusters.md`、`ai-gateway-api/design-docs/api-define/InnerAPI接口定义/server-data-conf.md` 中 `key_policy` 的字段说明 | +| 测试 | 新增 `bfe/bfe_server/reverseproxy_ai_affinity_test.go` 等单元测试;新增集成测试场景 | + +## 6. 详细设计 + +### 6.1 配置扩展 + +**文件:** `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` + +```go +type AIKeyPolicy struct { + Strategy string // "weighted_random" only in this version + MaxRetries int + RetryBackoffInitial int + RetryBackoffMax int + + // 新增 + SessionAffinity bool // 默认 false + SessionAffinityTTL int // 默认 300 秒 + SessionAffinityRedisPrefix string // 默认 "bfe:ai:key_affinity" + SessionAffinityPenaltyEnable bool // 默认 true +} +``` + +对应 OpenAPI / InnerAPI 的 `key_policy` 同步增加: + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `session_affinity` | bool | false | 是否开启基于 Redis + ClientKeyId 的会话级 Key 亲和 | +| `session_affinity_ttl` | int | 300 | Redis 绑定过期时间(秒) | +| `session_affinity_redis_prefix` | string | "bfe:ai:key_affinity" | Redis key 前缀 | +| `session_affinity_penalty_enable` | bool | true | 是否跳过近期 429/401/403 的 Key | + +Redis 连接优先复用 `mod_ai_rate_limit` 已配置的客户端;如需解耦,可在 `BfeServer` / `ReverseProxy` 中独立初始化一个 `redis_client.Client`。 + +### 6.2 Redis 数据结构 + +#### 6.2.1 客户绑定 + +``` +key: {prefix}:{cluster_name}:{client_key_id} +value: +TTL: session_affinity_ttl 秒 +``` + +示例: + +``` +bfe:ai:key_affinity:deepseek-cluster:ckt-abc-123 -> "key-primary" (TTL=300) +``` + +使用 `key_name` 而非数组下标,避免配置重载后下标变化导致绑定失效。 + +#### 6.2.2 Key 惩罚(可选) + +``` +key: {prefix}:penalty:{cluster_name}:{key_name} +value: +TTL: 429 为 60 秒;401/403 为 3600 秒 +``` + +### 6.3 Key 选择流程 + +**文件:** `bfe/bfe_server/reverseproxy.go` + +在 `aiClusterInvoke()` 中把原来的: + +```go +idx, key, ok = chooseNextAIKey(keys, state) +``` + +替换为: + +```go +idx, key, ok = chooseAIKeyWithAffinity(basicReq, cluster.Name, keys, policy, state, redisClient) +``` + +新增 helper 核心逻辑: + +```go +func chooseAIKeyWithAffinity( + basicReq *bfe_basic.Request, + clusterName string, + keys []cluster_conf.AIKey, + policy cluster_conf.AIKeyPolicy, + state *aiKeyAttemptState, + redisClient redis_client.Client, +) (int, cluster_conf.AIKey, bool) { + + if !policy.SessionAffinity || redisClient == nil { + return chooseNextAIKey(keys, state) + } + + sessionID := clientKeySessionID(basicReq) + if sessionID == "" { + return chooseNextAIKey(keys, state) + } + + // 单 Key 短路 + if len(keys) == 1 && keys[0].Weight > 0 { + return 0, keys[0], true + } + + candidateKeys := keys + if policy.SessionAffinityPenaltyEnable { + candidateKeys = filterPenaltyKeys(clusterName, keys, redisClient) + } + + boundName, err := redisGetBinding(clusterName, sessionID, policy, redisClient) + if err != nil { + log.Logger.Warn("aiKeyAffinity: redis get error[%v], fallback to random", err) + incAffinityRedisErr(clusterName) + return chooseNextAIKey(keys, state) + } + + if boundName != "" { + idx := findKeyIndexByName(boundName, keys) + if idx >= 0 && isKeyAlive(idx, keys, state) && + !isKeyPenalized(clusterName, boundName, redisClient) { + incAffinityHit(clusterName) + return idx, keys[idx], true + } + } + + idx, key, ok := chooseNextAIKey(candidateKeys, state) + if ok { + if err := redisSetBinding(clusterName, sessionID, key.Name, policy, redisClient); err != nil { + log.Logger.Warn("aiKeyAffinity: redis set error[%v]", err) + incAffinityRedisErr(clusterName) + } + } + return idx, key, ok +} + +func clientKeySessionID(basicReq *bfe_basic.Request) string { + if ai := basicReq.GetAiBasicInfo(); ai != nil && ai.ClientKeyId != "" { + return ai.ClientKeyId + } + return hashString(bfe_basic.GetApiKey(basicReq)) +} +``` + +Redis helper 示意: + +```go +func redisKey(clusterName, sessionID, prefix string) string { + return fmt.Sprintf("%s:%s:%s", prefix, clusterName, sessionID) +} + +func redisGetBinding(clusterName, sessionID string, policy cluster_conf.AIKeyPolicy, + client redis_client.Client) (string, error) { + key := redisKey(clusterName, sessionID, policy.SessionAffinityRedisPrefix) + val, err := client.Get(key) + if err != nil || val == nil { + return "", err + } + if b, ok := val.([]byte); ok { + return string(b), nil + } + return "", fmt.Errorf("unexpected redis value type %T", val) +} + +func redisSetBinding(clusterName, sessionID, keyName string, policy cluster_conf.AIKeyPolicy, + client redis_client.Client) error { + key := redisKey(clusterName, sessionID, policy.SessionAffinityRedisPrefix) + return client.Setex(key, []byte(keyName), policy.SessionAffinityTTL) +} +``` + +### 6.4 失败处理与重新绑定 + +在 `aiClusterInvoke()` 的失败处理分支中: + +| 状态码 | 处理 | +|---|---| +| 429 | 标记 `used`;写短 TTL 惩罚键;删除本客户对该 Key 的绑定;继续选新 Key | +| 401 / 402 / 403 | 标记 `dead`;写长 TTL 惩罚键;删除绑定;继续选新 Key | +| 5xx / 连接错误 | 同 Key 退避重试;重试耗尽后换 Key | +| 其他 4xx | 直接返回 | + +当最终成功且实际使用 Key 与绑定不一致时,更新 Redis 绑定: + +```go +if success && usedKey.Name != boundName { + redisSetBinding(clusterName, sessionID, usedKey.Name, policy, redisClient) +} +``` + +### 6.5 监控指标 + +新增 Counter 指标: + +| 指标 | 含义 | +|---|---| +| `ReqAiKeyAffinityHit` | 命中 Redis 绑定次数 | +| `ReqAiKeyAffinityMiss` | 未命中 Redis 绑定次数 | +| `ReqAiKeyAffinityRebind` | 因失败重新绑定次数 | +| `ReqAiKeyAffinityPenaltySkip` | 因惩罚跳过 Key 次数 | +| `ReqAiKeyAffinityRedisErr` | Redis 操作失败次数 | + +### 6.6 Redis 故障降级 + +| 场景 | 行为 | +|---|---| +| Redis Get 失败 | 记录错误指标,回退到 `chooseNextAIKey` 加权随机 | +| Redis Setex 失败 | 记录错误指标,仍按选择结果转发 | +| Redis 超时 | 设置较短读写超时,避免阻塞推理请求 | +| Redis 完全不可用 | 亲和能力退化为无状态加权随机,BFE 继续服务 | + +## 7. 关键代码索引 + +| 文件 | 行号范围(预计) | 说明 | +|---|---|---| +| `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` | `AIKeyPolicy` 结构体 | 新增亲和性配置字段 | +| `bfe/bfe_server/reverseproxy.go` | `aiClusterInvoke()` 附近 | Key 选择入口替换为亲和版本 | +| `bfe/bfe_server/reverseproxy.go` | 新增 helper | `chooseAIKeyWithAffinity`、`clientKeySessionID`、Redis 绑定读写、惩罚键处理 | +| `bfe/bfe_server/reverseproxy.go` | 失败处理分支 | 惩罚键写入、绑定删除、重新绑定 | +| `bfe/bfe_modules/mod_ai_rate_limit/mod_ai_rate_limit.go` | Redis 客户端初始化 | 复用或参考实现 | + +## 8. 测试计划 + +### 8.1 单元测试 + +新增 `bfe/bfe_server/reverseproxy_ai_affinity_test.go`: + +1. `TestChooseAIKeyWithAffinity_Hit`:Redis 命中绑定,返回对应 Key。 +2. `TestChooseAIKeyWithAffinity_Miss`:Redis 未命中,按加权随机选择并写入绑定。 +3. `TestChooseAIKeyWithAffinity_SingleKey`:单 Key 场景不访问 Redis。 +4. `TestChooseAIKeyWithAffinity_PenaltySkip`:被惩罚 Key 被跳过。 +5. `TestChooseAIKeyWithAffinity_RedisErrFallback`:Redis 故障时回退到加权随机。 +6. `TestChooseAIKeyWithAffinity_RebindOnSuccess`:失败后切换 Key 成功,更新绑定。 + +使用 mock `redis_client.Client` 或内存 Redis 实现。 + +### 8.2 集成测试 + +新增集成测试场景 `bfe/tests/integration/implementation/scenario-SCxx-ai-key-session-affinity/`,覆盖: + +1. 同一 `ClientKeyId` 的两次请求命中同一 Provider Key; +2. 亲和 Key 返回 429 后,后续请求切换到其他 Key; +3. Redis 不可用时请求仍能正常返回。 + +## 9. 影响范围 + +| 模块/文件 | 影响 | +|---|---| +| `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` | `AIKeyPolicy` 新增字段,旧配置默认关闭亲和性 | +| `bfe/bfe_server/reverseproxy.go` | Key 选择、失败处理、指标计数逻辑改动 | +| `bfe/bfe_modules/mod_ai_rate_limit/` | 可能复用其 Redis 客户端 | +| 控制面接口文档 | `key_policy` 增加字段说明 | +| `bfe/docs/zh_cn/modifications/2026-08-26-ai-key-session-affinity/design-changes.md` | 本设计变更文档 | + +## 10. 兼容性与风险 + +### 10.1 兼容性 + +- `session_affinity` 默认 `false`,未开启时行为与现有逻辑完全一致。 +- 单 Key 场景直接短路,不引入 Redis 调用。 +- Redis 故障时自动降级为加权随机,不影响请求成功率。 + +### 10.2 风险与缓解 + +| 风险 | 缓解措施 | +|---|---| +| 每次请求增加 Redis Get 延迟 | 设置读写超时(如 50ms);后续可加进程内 LRU 缓存 | +| 长会话导致某些 Key 负载倾斜 | 通过 `ReqAiKeyAffinityHit` 和各 Key 用量监控观察;必要时调整权重 | +| `ClientKeyId` 不稳定导致绑定失效 | 使用 `ClientKeyId` 而非原始 key 值;确认控制面更新 key 值时保持 ID 不变 | +| Redis 单点故障 | 生产环境使用 Redis Sentinel / Cluster 或 BNS 多地址 | +| Token 列表变化后旧绑定指向被删 Key | `findKeyIndexByName` 找不到时自动重新选择 | + +## 11. 参考 + +- `bfe/docs/zh_cn/sys_design/multi_api_key.md` +- `document-ai-gateway/迭代系统设计/v0.6/同cluster下多apikey轮转的会话保持/bfe多Key会话保持机制.md` +- `bfe/bfe_server/reverseproxy.go` +- `bfe/bfe_basic/request_ai_basic.go` +- `bfe/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go` +- `bfe/bfe_util/redis_client/client.go` +- `bfe/bfe_modules/mod_ai_rate_limit/mod_ai_rate_limit.go` diff --git a/docs/zh_cn/sys_design/multi_api_key.md b/docs/zh_cn/sys_design/multi_api_key.md index 0bcd93ee9..17fbadb62 100644 --- a/docs/zh_cn/sys_design/multi_api_key.md +++ b/docs/zh_cn/sys_design/multi_api_key.md @@ -39,6 +39,12 @@ type AIKeyPolicy struct { MaxRetries int // total retry budget within one aiClusterInvoke call RetryBackoffInitial int // ms RetryBackoffMax int // ms + + // 会话级 Key 亲和性 + SessionAffinity bool // 默认 false + SessionAffinityTTL int // Redis 绑定 TTL,单位秒,默认 300 + SessionAffinityRedisPrefix string // Redis key 前缀,默认 "bfe:ai:key_affinity" + SessionAffinityPenaltyEnable bool // 是否开启 Key 惩罚,默认 true } // ModelPrice represents a single model pricing entry @@ -391,6 +397,133 @@ func calcBackoff(initial, max, attempt int) time.Duration { } ``` +### 3.5 会话级 Key 亲和性(可选) + +当 `AIKeyPolicy.SessionAffinity = true` 时,BFE 会基于 `ClientKeyId` 在 Redis 中维护会话到 API-Key 的绑定,使得同一客户的多次请求尽量命中同一个 Provider Key,从而提升跨账户多 Key 场景下的 prompt cache 命中率。 + +#### 3.5.1 会话标识 + +使用 BFE 内部 `AiBasicInfo.ClientKeyId` 作为 session id: + +- 无需客户端传递额外 header/cookie; +- 比 TCP 连接更稳定(现代客户端普遍存在连接池 / HTTP/2 多路复用); +- 客户更新 API-Key 值时,只要 `ClientKeyId` 不变,绑定仍然有效。 + +#### 3.5.2 Redis 数据结构 + +**客户绑定:** + +``` +key: {prefix}:{cluster_name}:{client_key_id} +value: +TTL: session_affinity_ttl 秒 +``` + +示例: + +``` +bfe:ai:key_affinity:deepseek-cluster:ckt-abc-123 -> "key-primary" (TTL=300) +``` + +**Key 惩罚(可选):** + +``` +key: {prefix}:penalty:{cluster_name}:{key_name} +value: +TTL: 429 为 60 秒;401/403 为 3600 秒 +``` + +#### 3.5.3 选择流程 + +在 `aiClusterInvoke()` 中,将原来的 `chooseNextAIKey(keys, state)` 替换为 `chooseAIKeyWithAffinity(...)`: + +1. 若未开启亲和性、无 Redis 客户端或 `ClientKeyId` 为空,回退到加权随机; +2. 若 cluster 只有一个有效 Key,直接短路返回,不访问 Redis; +3. 若开启惩罚,先过滤被惩罚的 Key; +4. 从 Redis 读取 `ClientKeyId -> key_name` 绑定: + - 命中且 Key 仍有效:返回该 Key; + - 未命中:按候选列表加权随机选择,并写入新绑定。 + +```go +func chooseAIKeyWithAffinity( + basicReq *bfe_basic.Request, + clusterName string, + keys []cluster_conf.AIKey, + policy cluster_conf.AIKeyPolicy, + state *aiKeyAttemptState, + redisClient redis_client.Client, +) (int, cluster_conf.AIKey, bool) { + + if !policy.SessionAffinity || redisClient == nil { + return chooseNextAIKey(keys, state) + } + + sessionID := clientKeySessionID(basicReq) + if sessionID == "" { + return chooseNextAIKey(keys, state) + } + + if len(keys) == 1 && keys[0].Weight > 0 { + return 0, keys[0], true + } + + candidateKeys := keys + if policy.SessionAffinityPenaltyEnable { + candidateKeys = filterPenaltyKeys(clusterName, keys, redisClient) + } + + boundName, err := redisGetBinding(clusterName, sessionID, policy, redisClient) + if err != nil { + incAffinityRedisErr(clusterName) + return chooseNextAIKey(keys, state) + } + + if boundName != "" { + idx := findKeyIndexByName(boundName, keys) + if idx >= 0 && isKeyAlive(idx, keys, state) && + !isKeyPenalized(clusterName, boundName, redisClient) { + incAffinityHit(clusterName) + return idx, keys[idx], true + } + } + + idx, key, ok := chooseNextAIKey(candidateKeys, state) + if ok { + if err := redisSetBinding(clusterName, sessionID, key.Name, policy, redisClient); err != nil { + incAffinityRedisErr(clusterName) + } + } + return idx, key, ok +} +``` + +#### 3.5.4 失败处理与重新绑定 + +在 Key 级失败处理分支中增加: + +| 错误类型 | 原有处理 | 亲和性增强 | +|---|---|---| +| 429 | 标记 `used` | 写短 TTL 惩罚键;删除本客户绑定;切换成功后更新绑定 | +| 401 / 402 / 403 | 标记 `dead` | 写长 TTL 惩罚键;删除本客户绑定;切换成功后更新绑定 | +| 5xx / 连接错误 | 同 Key 退避重试 | 重试耗尽后换 Key,成功则更新绑定 | + +当最终成功且实际使用 Key 与绑定不一致时: + +```go +if success && usedKey.Name != boundName { + redisSetBinding(clusterName, sessionID, usedKey.Name, policy, redisClient) +} +``` + +#### 3.5.5 Redis 故障降级 + +| 场景 | 行为 | +|---|---| +| Redis Get 失败 | 记录错误指标,回退到加权随机 | +| Redis Setex 失败 | 记录错误指标,仍按选择结果转发 | +| Redis 超时 | 设置较短读写超时,避免阻塞推理请求 | +| Redis 完全不可用 | 亲和能力退化为无状态加权随机 | + --- ## 4. 失败分类与边界 @@ -448,6 +581,11 @@ func shouldTriggerFallback(res *bfe_http.Response, err error) bool { | `ReqAiKeyRotation` | Counter | Key 轮换次数(按 429/401/403 分类) | | `ReqAiKeyRetry` | Counter | Key 级重试次数 | | `ReqAiKeyExhausted` | Counter | Key 全部耗尽次数 | +| `ReqAiKeyAffinityHit` | Counter | 命中 Redis 绑定次数 | +| `ReqAiKeyAffinityMiss` | Counter | 未命中 Redis 绑定次数 | +| `ReqAiKeyAffinityRebind` | Counter | 因失败重新绑定次数 | +| `ReqAiKeyAffinityPenaltySkip` | Counter | 因惩罚跳过 Key 次数 | +| `ReqAiKeyAffinityRedisErr` | Counter | Redis 操作失败次数 | 关键日志: @@ -483,7 +621,11 @@ aiClusterInvoke: all ai keys exhausted for cluster[%s] "Strategy": "weighted_random", "MaxRetries": 3, "RetryBackoffInitial": 500, - "RetryBackoffMax": 5000 + "RetryBackoffMax": 5000, + "SessionAffinity": true, + "SessionAffinityTTL": 300, + "SessionAffinityRedisPrefix": "bfe:ai:key_affinity", + "SessionAffinityPenaltyEnable": true }, "ModelMapping": { "gpt-4": "deepseek-v3" @@ -521,4 +663,7 @@ aiClusterInvoke: all ai keys exhausted for cluster[%s] 1. **请求体可回退性**:Key 级重试依赖 `basicReq.HttpRequest.Body` 可重复读取。`aiClusterInvoke()` 会在启用 Key 级重试前调用 `prepareRequestBodyForRetry()`; 2. **SSE 流式响应**:所有 Key 尝试完成后才返回响应,不会出现已开始发送后切换 Key 的情况; 3. **与 `ServeHTTP()` 隔离**:多 API-Key 逻辑仅作用于 `ServeHTTPForAI()` 路径; -4. **旧字段清理**:`AIConf.Key` 不再保留,统一使用 `AIConf.Keys`。 +4. **旧字段清理**:`AIConf.Key` 不再保留,统一使用 `AIConf.Keys`; +5. **会话级亲和性依赖 Redis**:`SessionAffinity` 默认关闭,开启后需确保 `mod_ai_rate_limit` 的 Redis 配置可用;Redis 故障时自动降级为加权随机; +6. **单 Key 短路**:当 `AIConf.Keys` 中只有一个有效 Key 时,无论是否开启亲和性,都不会访问 Redis; +7. **ClientKeyId 稳定性**:会话绑定基于 `AiBasicInfo.ClientKeyId`,控制面更新 API-Key 值时应保持该 ID 不变,否则原有绑定会失效。 diff --git a/skills/bfe-rd-workflow/SKILL.md b/skills/bfe-rd-workflow/SKILL.md new file mode 100644 index 000000000..b0e852421 --- /dev/null +++ b/skills/bfe-rd-workflow/SKILL.md @@ -0,0 +1,198 @@ +--- +name: bfe-rd-workflow +description: 引导用户在 bfe 代码库中完成一次完整的功能研发流程,包括需求对齐、文档修改、代码实现、集成测试与回归验证。 +--- + +# BFE 代码库研发流程 + +本 Skill 适用于在 `bfe/` 目录中新增或修改 BFE 功能(例如 AI Gateway 的多 API-Key 会话亲和性、限流、计费、路由、协议支持等)的完整研发流程。 + +## 触发语 + +当用户提出以下类型请求时启用本流程: + +- “我要实现 xxx 功能” +- “请帮我完成 xxx 的代码与测试” +- “请在 BFE 中增加/修改 xxx” + +## 执行原则 + +1. **分阶段暂停确认**:本流程将研发拆分为多个 Phase。每个 Phase 执行完毕后,必须暂停并等待用户确认“可以继续”后,再进入下一个 Phase。不要在未获得用户确认的情况下自动推进到下一阶段。 +2. **Git 提交前确认**:在任何 `git commit`、`git push` 或其他会改变 Git 仓库状态的操作之前,必须先向用户说明变更内容并取得明确同意。禁止自动执行 Git 提交或推送。 +3. **Git push 默认目标为 origin**:如果获得用户授权执行 `git push`,默认推送到 `origin` 远程仓库,而不是 `upstream`。除非用户明确指定其他 remote,否则不使用 `upstream`。 + +## 研发阶段 + +### Phase 0. 需求澄清与范围界定 + +1. 让用户明确: + - 功能目标(一句话描述) + - 验收标准(必须通过的测试/行为) + - 影响范围(是否改配置协议、是否改 Redis/状态、是否影响既有请求路径) +2. 判断是否为“非平凡改动”(多文件、有架构选择、用户偏好影响实现): + - 是 → 调用 `EnterPlanMode`,先出设计文档/plan 再执行。 + - 否 → 直接进入 Phase 1。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 1. 修改 modifications 文档 + +BFE 侧的改动必须在 `bfe/docs/zh_cn/modifications/` 留下修改说明,便于后续维护与审计。 + +1. 在 `bfe/docs/zh_cn/modifications/` 下新建日期化目录,例如: + ``` + bfe/docs/zh_cn/modifications/2026-08-26-ai-key-session-affinity/ + ``` +2. 在该目录下创建 `design-changes.md`,包含: + - 背景与目标 + - 主要改动点(数据结构、模块行为、新增字段) + - 配置影响 + - 兼容性说明 +3. 如已有同主题目录,则更新其中的文档,不要重复创建。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 2. 更新 configuration 文档 + +如果改动涉及配置文件(`cluster_conf.data`、`mod_*.conf`、`bfe.conf` 等),必须同步更新中英文配置文档: + +1. 中文:`bfe/docs/zh_cn/configuration/` +2. 英文:`bfe/docs/en_us/configuration/` + +更新原则: + +- 新增字段要说明类型、默认值、是否必填、示例。 +- 修改字段要说明前后行为差异。 +- 两个语言目录尽量保持内容一致。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 3. 更新 sys_design 文档 + +如果改动涉及系统设计、协议交互或状态存储,需要更新 `bfe/docs/zh_cn/sys_design/`: + +1. 查找现有相关文档(例如 `multi_api_key.md`)。 +2. 在文档中新增/修改对应章节,说明: + - 方案选型 + - 数据流 + - 状态存储(如 Redis key 格式、TTL) + - 边界情况与优化 +3. 如需新增独立文档,直接创建 `.md` 文件,并在相关文档中建立链接。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 4. 代码实现 + +1. 阅读相关源码,确认: + - 配置结构体(`bfe_config/bfe_cluster_conf/cluster_conf/`) + - 模块加载与处理逻辑(`bfe_modules/mod_*`) + - 反向代理与 Key 选择逻辑(`bfe_server/reverseproxy.go` 及相关测试) +2. 做最小改动,优先匹配现有代码风格: + - 不引入未使用的依赖。 + - 不修改与本次需求无关的逻辑。 +3. 关键实现完成后,先跑模块级单元测试: + ```bash + cd bfe + go test ./bfe_server/... ./bfe_modules/<相关模块>/... + ``` + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 5. 编写集成测试设计文档 + +BFE 的集成测试设计文档位于 `bfe/tests/integration/测试设计文档/`。在写测试代码之前,先补充对应场景的设计文档,便于后续维护与评审。 + +1. 在 `bfe/tests/integration/测试设计文档/` 下新建 scenario 目录,命名规范: + ``` + scenario-SC<序号>-<简短中文描述> + ``` +2. 编写 `场景说明.md`,包含: + - 场景背景与目的 + - 运行模式 + - 涉及的 BFE 配置文件 + - 测试 Cluster 与关键配置(AIConf、KeyPolicy、路由表等) + - 测试例列表 + - 每个测试例的详细说明(目的、前置条件、请求构造、执行步骤、预期结果、清理) + - 公共基础设施 + - 与其他场景的依赖关系 +3. 为每个测试例编写独立的 `TC-<序号>-<测试例名称>.md`,结构与现有 TC 文档保持一致。 +4. 更新 `bfe/tests/integration/测试设计文档/测试场景总体说明.md`: + - 在“场景清单”中新增一行; + - 在“场景与测试例对应关系”中新增该 scenario 的 TC 列表。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 6. 编写集成测试代码 + +BFE 的集成测试代码位于 `bfe/tests/integration/implementation/`。 + +1. 选择最接近的现有 scenario 作为模板(例如需要 Redis 的可参考 `scenario-SC07-ai-rate-limit-redis-key`)。 +2. 新建 scenario 目录,命名规范: + ``` + scenario-SC<序号>-<简短英文描述> + ``` +3. 复制模板后,修改 `testdata/` 中的: + - `server_data_conf/host_rule.data`:host + - `server_data_conf/route_rule.data`:cluster + - `cluster_conf/gslb.data`:cluster 名称 + - `mod_ai_route/ai_route.data`:apikey owner / route binding + - `mod_ai_token_auth/mod_ai_token_auth.conf`、`mod_ai_rate_limit/mod_ai_rate_limit.conf` 等 +4. 编写 `_test.go`,覆盖: + - 正常路径 + - 功能开关开启/关闭对比 + - 边界场景(单 Key、多 ClientKeyId、BFE 重启后状态保持、错误码/降级) +5. 运行集成测试: + ```bash + cd bfe + go test ./tests/integration/implementation/scenario-SC-<描述>/... -v + ``` + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 7. 回归验证 + +1. 运行本次新增 scenario 的测试。 +2. 运行与被改动模块相关的既有 scenario: + - 多 API-Key 轮换:`scenario-SC02-multi-api-key` + - Redis 限流:`scenario-SC07-ai-rate-limit-redis-key` +3. 运行相关单元测试: + ```bash + go test ./bfe_server/... ./bfe_modules/mod_ai_route/... ./bfe_modules/mod_ai_token_auth/... + ``` +4. 如有失败,优先修复;修复后再次回归,直到全部通过。 + +完成本阶段后暂停,等待用户确认后再进入下一阶段。 + +### Phase 8. 收尾与总结 + +1. 检查是否有注释/文档描述的是旧行为,及时同步更新。 +2. 向用户汇报: + - 改动了哪些文件 + - 新增/修改了哪些测试 + - 验证结果 + - 仍存在的风险或待决策点(如有) + +3. **Git 提交前必须人工确认**:如果用户要求或流程需要执行 `git commit`、`git push` 等 Git 操作,必须先向用户清晰说明本次提交内容(包含文件清单与主要变更摘要),并取得明确同意后再执行。禁止在未获授权的情况下自动提交或推送。获得授权后,`git push` 默认推送到 `origin`,不要推送到 `upstream`,除非用户明确指定。 + +## 常见陷阱 + +- **cluster 名称不一致**:`route_rule.data`、`gslb.data`、ai_route 中的 cluster 名称必须一致,否则 BFE 启动会报 `no backend conf`。 +- **ApikeyRouteTableBindings 遗漏**:新增 client apiKey 时,必须在 `mod_ai_route/ai_route.data` 的 `ApikeyRouteTableBindings` 中为其绑定路由表。 +- **Redis 绑定 key 格式**:确认代码中实际使用的 key 格式(如 `bfe:ai:key_affinity::`)。 +- **单 Key 优化**:如果实现了“单 Key 不走 Redis”,需要同时避免读和写,否则集成测试里 `Exists` 会误判。 +- **BFE 端口冲突**:集成测试里 `processEnv.StartBFE` 会自动分配端口,不要手动写死。 + +## 推荐命令速查 + +```bash +# 模块单元测试 +cd bfe +go test ./bfe_server/... ./bfe_modules/mod_ai_route/... ./bfe_modules/mod_ai_token_auth/... + +# 单个集成测试场景(详细日志) +go test ./tests/integration/implementation/scenario-SC-<描述>/... -v + +# 相关场景回归 +go test ./tests/integration/implementation/scenario-SC02-multi-api-key/... +go test ./tests/integration/implementation/scenario-SC07-ai-rate-limit-redis-key/... +``` diff --git a/tests/integration/common/redis_server.go b/tests/integration/common/redis_server.go index 50a41ceb4..a33d373c6 100644 --- a/tests/integration/common/redis_server.go +++ b/tests/integration/common/redis_server.go @@ -66,3 +66,8 @@ func (s *RedisServer) GetQuota(key string) int64 { } return value } + +// Exists reports whether the given key is present in redis. +func (s *RedisServer) Exists(key string) bool { + return s.server.Exists(key) +} diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/sc10_ai_key_session_affinity_test.go b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/sc10_ai_key_session_affinity_test.go new file mode 100644 index 000000000..3ef316904 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/sc10_ai_key_session_affinity_test.go @@ -0,0 +1,577 @@ +// Copyright (c) 2026 The BFE Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sc10 + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/bfenetworks/bfe/bfe_config/bfe_cluster_conf/cluster_conf" + "github.com/bfenetworks/bfe/tests/integration/common" +) + +const ( + apiHost = "session-affinity.example.org" + apiPath = "/v1/chat/completions" + apiKey = "ak_session_affinity" + apiKeyId = "session_affinity_key_id" + apiKey2 = "ak_session_affinity_2" + apiKeyId2 = "session_affinity_key_id_2" + + clusterSessionAffinity = "cluster_session_affinity" + + keyA = "sk-key-a" + keyB = "sk-key-b" + keyC = "sk-key-c" +) + +var defaultBody = []byte(`{"model":"gpt-4"}`) + +// testEnv holds all resources for a single SC10 integration test. +type testEnv struct { + t *testing.T + processEnv *common.ProcessEnv + backend *common.MockBackend + redis *common.RedisServer + confDir string + logDir string + bfePort int + stopBFE func() + aiKeys []cluster_conf.AIKey + clientTokens map[string]string // apiKey -> keyId +} + +// testEnvOption customizes a test environment before BFE is started. +type testEnvOption func(*testEnv) + +// withAIKeys overrides the AI keys configured for the cluster. +func withAIKeys(keys []cluster_conf.AIKey) testEnvOption { + return func(e *testEnv) { + e.aiKeys = keys + } +} + +// withClientTokens overrides the client API keys used to authenticate requests. +// The map key is the raw apiKey value, the map value is the stable ClientKeyId. +func withClientTokens(tokens map[string]string) testEnvOption { + return func(e *testEnv) { + e.clientTokens = tokens + } +} + +func newTestEnv(t *testing.T, enableAffinity bool, opts ...testEnvOption) *testEnv { + e := &testEnv{t: t} + + for _, opt := range opts { + opt(e) + } + + if e.aiKeys == nil { + e.aiKeys = []cluster_conf.AIKey{ + {Name: "key-a", Key: keyA, Weight: 50}, + {Name: "key-b", Key: keyB, Weight: 30}, + {Name: "key-c", Key: keyC, Weight: 20}, + } + } + if e.clientTokens == nil { + e.clientTokens = map[string]string{ + apiKey: apiKeyId, + } + } + + e.backend = common.NewMockBackend(clusterSessionAffinity, http.StatusOK, `{"ok":true}`) + e.redis = common.NewRedisServer(t) + + e.processEnv = common.NewProcessEnv(t) + e.processEnv.Build() + + e.confDir = filepath.Join(e.processEnv.WorkDir(), "conf") + e.logDir = filepath.Join(e.processEnv.WorkDir(), "log") + + e.startBFE(enableAffinity) + return e +} + +func (e *testEnv) startBFE(enableAffinity bool) { + tokenRule := e.buildTokenRule() + + bindings := make(map[string][]string) + for clientKey := range e.clientTokens { + bindings[clientKey] = []string{"rlp-pass"} + } + + rateLimitPolicy := &common.RateLimitPolicyData{ + Version: "1.0", + Config: map[string][]common.RateLimitProductRule{ + "ai_product": { + { + Cond: "default_t()", + HitAction: struct { + Cmd string `json:"cmd"` + Params []string `json:"params,omitempty"` + }{Cmd: "FINISH"}, + }, + }, + }, + RateLimitPolicies: map[string]common.RateLimitPolicy{ + "rlp-pass": { + Name: "rlp-pass", + Enabled: true, + Rules: struct { + TPM []common.RateLimitRule `json:"tpm,omitempty"` + RPM []common.RateLimitRule `json:"rpm,omitempty"` + MaxConcurrency *int64 `json:"max_concurrency,omitempty"` + }{ + RPM: []common.RateLimitRule{ + { + Name: "rpm-pass", + WindowMinutes: 1, + MaxRequests: 1000000, + Models: []string{"*"}, + RedisKey: "RL_RPM_rlp-pass_0", + }, + }, + }, + }, + }, + ApikeyRateLimitPolicyBindings: bindings, + } + + aiConf := &cluster_conf.AIConf{ + Type: 0, + Keys: e.aiKeys, + KeyPolicy: &cluster_conf.AIKeyPolicy{ + Strategy: "weighted_random", + MaxRetries: 3, + RetryBackoffInitial: 50, + RetryBackoffMax: 200, + SessionAffinity: enableAffinity, + SessionAffinityTTL: 300, + SessionAffinityRedisPrefix: "bfe:ai:key_affinity", + SessionAffinityPenaltyEnable: true, + }, + } + + backends := map[string]*common.MockBackend{ + clusterSessionAffinity: e.backend, + } + + builder := &common.BFEConfigBuilder{ + TemplateDir: "testdata", + TargetConfDir: e.confDir, + Backends: backends, + RedisAddr: e.redis.Addr(), + TokenRuleData: tokenRule, + RateLimitPolicyData: rateLimitPolicy, + AIConfs: map[string]*cluster_conf.AIConf{ + clusterSessionAffinity: aiConf, + }, + } + if err := builder.Build(); err != nil { + e.t.Fatalf("build bfe config failed: %v", err) + } + + e.bfePort, _, e.stopBFE = e.processEnv.StartBFE(e.confDir, e.logDir) +} + +func (e *testEnv) buildTokenRule() *common.TokenRuleData { + tokens := make(map[string]common.TokenFile) + for clientKey, keyId := range e.clientTokens { + tokens[clientKey] = common.TokenFile{ + Key: clientKey, + KeyId: keyId, + Enabled: true, + ExpiredTime: -1, + UnlimitedQuota: true, + QuotaPlans: []string{"unlimited_plan"}, + } + } + + return &common.TokenRuleData{ + Version: "1.0", + QuotaPlans: map[string][]common.QuotaPlan{ + "ai_product": { + { + Id: "unlimited_plan", + Unlimited: true, + PassNoQuota: false, + RedisKey: "quota:unlimited_plan", + ExpiredTime: -1, + Quota: 0, + Unit: "total_token", + }, + }, + }, + Tokens: map[string]map[string]common.TokenFile{ + "ai_product": tokens, + }, + Config: map[string][]common.TokenRule{ + "ai_product": { + { + Cond: "default_t()", + Action: common.ActionFile{Cmd: "CHECK_TOKEN"}, + }, + }, + }, + } +} + +func (e *testEnv) Close() { + if e.stopBFE != nil { + e.stopBFE() + } + if e.backend != nil { + e.backend.Close() + } + if e.redis != nil { + e.redis.Close() + } +} + +func (e *testEnv) logBFEException() { + data, err := os.ReadFile(filepath.Join(e.logDir, "exception.log")) + if err == nil && len(data) > 0 { + e.t.Logf("bfe exception log:\n%s", string(data)) + } + + logPath := filepath.Join(e.logDir, "bfe.log") + logData, err := os.ReadFile(logPath) + if err == nil && len(logData) > 0 { + lines := strings.Split(string(logData), "\n") + start := 0 + if len(lines) > 200 { + start = len(lines) - 200 + } + e.t.Logf("bfe log tail:\n%s", strings.Join(lines[start:], "\n")) + } +} + +func (e *testEnv) sendRequest() (*http.Response, string, error) { + return e.sendRequestWithKey(apiKey) +} + +func (e *testEnv) sendRequestWithKey(clientKey string) (*http.Response, string, error) { + url := fmt.Sprintf("http://127.0.0.1:%d%s", e.bfePort, apiPath) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(defaultBody)) + if err != nil { + return nil, "", err + } + req.Host = apiHost + req.Header.Set("Authorization", "Bearer "+clientKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + return resp, string(respBody), nil +} + +func redisBindingKey(clientKeyId string) string { + return fmt.Sprintf("bfe:ai:key_affinity:%s:%s", clusterSessionAffinity, clientKeyId) +} + +func countAuthHeaders(headers []string) map[string]int { + counts := make(map[string]int) + for _, h := range headers { + counts[h]++ + } + return counts +} + +// TestTC01 verifies that with SessionAffinity enabled, repeated requests from +// the same ClientKeyId hit the same provider API key. +func TestTC01_SessionAffinityHitsSameKey(t *testing.T) { + e := newTestEnv(t, true) + defer e.Close() + + for i := 0; i < 50; i++ { + resp, body, err := e.sendRequest() + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + } + + if e.backend.Hits() != 50 { + t.Fatalf("expected 50 backend hits, got %d", e.backend.Hits()) + } + + counts := countAuthHeaders(e.backend.AuthHeaders()) + nonZero := 0 + for k, c := range counts { + if c > 0 { + t.Logf("key %s used %d times", k, c) + nonZero++ + } + } + if nonZero != 1 { + t.Fatalf("expected exactly 1 key to be used with affinity, got %d", nonZero) + } +} + +// TestTC02 verifies that when SessionAffinity is disabled, requests are +// distributed across multiple API keys. +func TestTC02_NoAffinityDistributesKeys(t *testing.T) { + e := newTestEnv(t, false) + defer e.Close() + + for i := 0; i < 200; i++ { + resp, body, err := e.sendRequest() + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + } + + counts := countAuthHeaders(e.backend.AuthHeaders()) + nonZero := 0 + for _, c := range counts { + if c > 0 { + nonZero++ + } + } + if nonZero < 2 { + t.Fatalf("expected multiple keys to be used without affinity, got %d", nonZero) + } +} + +// TestTC03 verifies that when the bound key returns 429, BFE rotates to +// another key and updates the Redis binding. +func TestTC03_429RotatesAndRebinds(t *testing.T) { + e := newTestEnv(t, true) + defer e.Close() + + // First request establishes the binding. + resp, body, err := e.sendRequest() + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + counts := countAuthHeaders(e.backend.AuthHeaders()) + var boundKey string + for k, c := range counts { + if c > 0 { + boundKey = k + break + } + } + if boundKey == "" { + t.Fatal("expected a bound key after first request") + } + t.Logf("bound key: %s", boundKey) + + // Make the bound key return 429. + e.backend.ResponseFunc = func(r *http.Request, count int) (int, string) { + if r.Header.Get("Authorization") == boundKey { + return http.StatusTooManyRequests, `{"error":"rate limited"}` + } + return http.StatusOK, `{"ok":true}` + } + + // Subsequent requests should switch to a different key. + for i := 0; i < 20; i++ { + resp, body, err = e.sendRequest() + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + } + + // The bound key may still be tried once per request (and get 429), but the + // successful attempts should use a different key. + finalCounts := countAuthHeaders(e.backend.AuthHeaders()) + delete(finalCounts, boundKey) + successOther := false + for k, c := range finalCounts { + if c > 0 { + t.Logf("other key used: %s count=%d", k, c) + successOther = true + } + } + if !successOther { + t.Fatal("expected at least one request to succeed with a different key after 429") + } +} + +// TestTC04 verifies the single-key optimization: when a cluster is configured +// with only one API key, BFE does not write a Redis binding and all requests +// use that key. +func TestTC04_SingleKeySkipsRedisBinding(t *testing.T) { + e := newTestEnv(t, true, + withAIKeys([]cluster_conf.AIKey{ + {Name: "key-a", Key: keyA, Weight: 100}, + }), + ) + defer e.Close() + + for i := 0; i < 10; i++ { + resp, body, err := e.sendRequest() + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + } + + counts := countAuthHeaders(e.backend.AuthHeaders()) + if len(counts) != 1 { + t.Fatalf("expected exactly one provider key, got %d", len(counts)) + } + + if e.redis.Exists(redisBindingKey(apiKeyId)) { + t.Fatal("expected no Redis binding for single-key cluster") + } +} + +// TestTC05 verifies that two different ClientKeyIds maintain independent +// bindings. Each client keyId should stick to exactly one provider key, and +// the two chosen provider keys may differ. +func TestTC05_DifferentClientKeyIdsAreIndependent(t *testing.T) { + e := newTestEnv(t, true, + withClientTokens(map[string]string{ + apiKey: apiKeyId, + apiKey2: apiKeyId2, + }), + ) + defer e.Close() + + for i := 0; i < 20; i++ { + resp, body, err := e.sendRequestWithKey(apiKey) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + } + for i := 0; i < 20; i++ { + resp, body, err := e.sendRequestWithKey(apiKey2) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + } + + for _, clientKeyId := range []string{apiKeyId, apiKeyId2} { + bindingKey := redisBindingKey(clientKeyId) + if !e.redis.Exists(bindingKey) { + t.Fatalf("expected Redis binding to exist for client key id %s", clientKeyId) + } + } + + allHeaders := e.backend.AuthHeaders() + firstGroup := countAuthHeaders(allHeaders[:20]) + secondGroup := countAuthHeaders(allHeaders[20:]) + + if len(firstGroup) != 1 { + t.Fatalf("expected first ClientKeyId to use exactly one provider key, got %d", len(firstGroup)) + } + if len(secondGroup) != 1 { + t.Fatalf("expected second ClientKeyId to use exactly one provider key, got %d", len(secondGroup)) + } +} + +// TestTC06 verifies that the Redis binding outlives a BFE process restart: +// after stopping and restarting BFE with the same Redis server, the same +// ClientKeyId continues to hit the previously bound provider key. +func TestTC06_BindingPersistsAcrossBFERestart(t *testing.T) { + e := newTestEnv(t, true) + defer e.Close() + + for i := 0; i < 10; i++ { + resp, body, err := e.sendRequest() + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + } + + boundKey := "" + for _, h := range e.backend.AuthHeaders() { + if h != "" { + boundKey = h + break + } + } + if boundKey == "" { + t.Fatal("expected a bound provider key before restart") + } + t.Logf("bound key before restart: %s", boundKey) + + // Record how many requests have been seen so far. + hitsBefore := e.backend.Hits() + + // Stop BFE but keep backend and redis alive. + e.stopBFE() + e.stopBFE = nil + + // Restart BFE with the same configuration and Redis server. + e.startBFE(true) + + for i := 0; i < 10; i++ { + resp, body, err := e.sendRequest() + if err != nil { + t.Fatalf("send request after restart failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200 after restart, got %d, body: %s", resp.StatusCode, body) + } + } + + // Only the requests sent after the restart should be checked. + afterHeaders := e.backend.AuthHeaders()[hitsBefore:] + for i, h := range afterHeaders { + if h != boundKey { + t.Fatalf("request %d after restart used provider key %s, expected %s", i, h, boundKey) + } + } +} diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/bfe.conf b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/bfe.conf new file mode 100644 index 000000000..27c5fecd7 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/bfe.conf @@ -0,0 +1,38 @@ +[server] +httpPort = 18080 +httpsPort = 18443 +monitorPort = 18081 +httpAddr = "127.0.0.1" +httpsAddr = "127.0.0.1" +monitorAddr = "127.0.0.1" +MonitorEnabled = true +maxCpus = 1 + +TlsHandshakeTimeout = 30 +ClientReadTimeout = 5 +ClientWriteTimeout = 5 +KeepAliveEnabled = true +GracefulShutdownTimeout = 10 + +EnableAiGateway = true + +accessibleBodySize = 4194304 + +# max total bytes of all active bytes_body buffers (0 means unlimited) +totalBodyBufferSize = 0 + +Modules = mod_ai_token_auth +Modules = mod_ai_route +Modules = mod_ai_rate_limit + +hostRuleConf = server_data_conf/host_rule.data +routeRuleConf = server_data_conf/route_rule.data +vipRuleConf = server_data_conf/vip_rule.data + +clusterTableConf = cluster_conf/cluster_table.data +gslbConf = cluster_conf/gslb.data +clusterConf = cluster_conf/cluster_conf.data +NameConf = + +maxHeaderUriBytes = 8096 +maxHeaderBytes = 8096 diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/cluster_conf/gslb.data b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/cluster_conf/gslb.data new file mode 100644 index 000000000..570b470f0 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/cluster_conf/gslb.data @@ -0,0 +1,10 @@ +{ + "clusters": { + "cluster_session_affinity": { + "GSLB_BLACKHOLE": 0, + "sub_sub": 100 + } + }, + "hostname": "gslb-test", + "ts": "20260720150000" +} diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_rate_limit/mod_ai_rate_limit.conf b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_rate_limit/mod_ai_rate_limit.conf new file mode 100644 index 000000000..a20ee9896 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_rate_limit/mod_ai_rate_limit.conf @@ -0,0 +1,14 @@ +[basic] +ProductRulePath = mod_ai_rate_limit/ai_rate_limit.data +IsRejectOnRedisError = false + +[redis] +Bns = 127.0.0.1:6379 +ConnectTimeout = 1000 +ReadTimeout = 1000 +WriteTimeout = 1000 +MaxIdle = 10 +MaxActive = 20 + +[log] +OpenDebug = true diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_route/ai_route.data b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_route/ai_route.data new file mode 100644 index 000000000..38d0152f8 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_route/ai_route.data @@ -0,0 +1,31 @@ +{ + "Version": "20260720150000", + "route_rules": { + "apikey_ak_session_affinity": { + "type": "apikey", + "owner": "ak_session_affinity", + "rules": [ + { + "name": "session-affinity-default", + "Cond": "default_t()", + "targets": [ + { + "ClusterName": "cluster_session_affinity", + "Model": "", + "Weight": 100 + } + ], + "fallbacks": [] + } + ] + } + }, + "ApikeyRouteTableBindings": { + "ak_session_affinity": [ + "apikey_ak_session_affinity" + ], + "ak_session_affinity_2": [ + "apikey_ak_session_affinity" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_route/mod_ai_route.conf b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_route/mod_ai_route.conf new file mode 100644 index 000000000..f250013bb --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_route/mod_ai_route.conf @@ -0,0 +1,5 @@ +[basic] +RouteRulePath = mod_ai_route/ai_route.data + +[log] +OpenDebug = true diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_token_auth/mod_ai_token_auth.conf b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_token_auth/mod_ai_token_auth.conf new file mode 100644 index 000000000..966b473d0 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/mod_ai_token_auth/mod_ai_token_auth.conf @@ -0,0 +1,13 @@ +[basic] +ProductRulePath = mod_ai_token_auth/token_rule.data + +[redis] +Bns = 127.0.0.1:6379 +ConnectTimeout = 1000 +ReadTimeout = 1000 +WriteTimeout = 1000 +MaxIdle = 10 +MaxActive = 20 + +[log] +OpenDebug = true diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/server_data_conf/host_rule.data b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/server_data_conf/host_rule.data new file mode 100644 index 000000000..3123fed52 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/server_data_conf/host_rule.data @@ -0,0 +1,13 @@ +{ + "Version": "20260720150000", + "Hosts": { + "ai_product": [ + "session-affinity.example.org" + ] + }, + "HostTags": { + "ai_product": [ + "ai_product" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/server_data_conf/route_rule.data b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/server_data_conf/route_rule.data new file mode 100644 index 000000000..140f79298 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/server_data_conf/route_rule.data @@ -0,0 +1,12 @@ +{ + "Version": "20260720150000", + "BasicRule": { + "ai_product": [ + { + "Hostname": ["*"], + "Path": ["*"], + "ClusterName": "cluster_session_affinity" + } + ] + } +} diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/server_data_conf/vip_rule.data b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/server_data_conf/vip_rule.data new file mode 100644 index 000000000..6fe22f03a --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/server_data_conf/vip_rule.data @@ -0,0 +1,8 @@ +{ + "Version": "20260720150000", + "Vips": { + "ai_vip": [ + "127.0.0.1" + ] + } +} diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/bfe_i_ca.crt b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/bfe_i_ca.crt new file mode 100644 index 000000000..f1e78f73c --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/bfe_i_ca.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDwDCCAqigAwIBAgIBCzANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzExMDMx +NDAxNDZaFw0zNzA3MTIxNDAxNDZaMIGQMQswCQYDVQQGEwJjbjEQMA4GA1UECAwH +YmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsMC3lpbmdmZWkt +ZGV2MRgwFgYDVQQDDA95aW5nZmVpLWRldi1pY2ExKTAnBgkqhkiG9w0BCQEWGmxp +YW5nY2h1YW5AeWYtbmV0d29ya3MuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA6eFuWgoknixrRO9NCX4jAKyLtcAOWVJqVN2yX7CxjZjLyPurjvTZ +W73NYUbrPAN4AB5gY6UAPzuiEOSopVVIZ0OschK0cJldu9vZ0mZObBOsovuFcLQq +dgNSJ5slJSk7tCgD2EnCB3GYPG4D+uIKYd0c49wzTWWv4bjDwpgnf0LQbFpy7GhN +7D59zFH4qgOK/IQ5vaTMGyvIvtWR5/1Gvc9MLpGopTgi0DiNLed4UwDYrod5kysl +q3UcB5puONHQISOVoD3uRxo7wdsmVsHUfW7YfAWkhi6ec8mx9fy8IyE6f7GlXnuV +ysNccwqyEotL5bOXPJCqwUCL1v+iKTMPWwIDAQABo1AwTjAdBgNVHQ4EFgQUCjvC +vDVr8QXh9/hMI5xBgNvoSFgwHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/oqHdW +1D0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAhIggK/6JK4N7+HZW +VoTemwRpilugZZyKrcVAHbiiwUfXVQVuI64vc+yHWMSFRD1mykHkKBFzxEoDabMl +ASBtUJNt4b4zEL9V7k295vmAOp2IdLUQxlgeKWqABm4DGX96tGR9nKQTcn1ZeAxx +NyQFV1aj+dnNcF1iFFNF6t0bRrOEZ/aRSiu1bWp3Dj1JYTjXbyyplh3Ktb8lv4lt +EmvCXjo/l4TgQC9233kcTkXcq1swppzkkXfhB0NVuf9DE3C2xAWX0b2FiIEqnAhl +Bx0Cn3RrX7PZ6qrdRL6oBKvGJV6DP8BlF4LXiuD1VN/waQFJha57KQ78ZYYIXxQg +WMjWgA== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/bfe_r_ca.crt b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/bfe_r_ca.crt new file mode 100644 index 000000000..2a6db1e49 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/bfe_r_ca.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDkzCCAnugAwIBAgIBCjANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzExMDMx +MzQ5NTVaFw0zNzA3MTIxMzQ5NTVaMGQxCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEUMBIGA1UECwwLeWluZ2ZlaS1k +ZXYxFzAVBgNVBAMMDnlpbmdmZWktZGV2LWNhMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAvNA3HrsMjBcXrMIIhGVWsurIA1F9jxKeA7dh06H00Vt4inVV +SUvNFrTqgPRhLkAhGRMPxrjVRgJ5bbFqqXIuPIpUFBhUsWXIDH+oVXQl9jsxAXaG +gZ0lTO/uYR9qyrS1rj9nyNPwRf59Al/VlsQL71cNQ/T/agJ4PfvPfULTPLOsqclJ +hj0IgXmDj464dqcdG3ZdfXpfhNF6ab+8YjpwafTRmY+LoV8qjUwsYeJMcW4N8pxJ +8F2ktZj9J6uWepNGj+87ZeXg9XquzC62ASIFzPjoE1WN//Q518EqizxhuLGBDpK3 +6sEYUK4kHYUL4gZKFPlKTPXIl0ZsJIM5PsBMKwIDAQABo1AwTjAdBgNVHQ4EFgQU +DZloT8VhVbysD819oG/oqHdW1D0wHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/o +qHdW1D0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEALgB+IcN3UD4T ++4g8jaOyOiSUpUVdYqW+3go5DppqnvlGNq99N2cXKqssVPa/T9TikEcBEicFa8zU +bwlx6TEte+MkWfWdQxFR1EuI1FgKr3ps6ZBRr7MPpnYmI7K9aK372K9n7WrQhbmP +s7ult8bWB1/t6o3R7B9ChNkWT+7DPD4+FvB1GMJSGPno7cdnvDkevBOuC2DnQl3M ++ADFAge1Lo8wKBy6gYkNFd2BfarHGvRC5Qmmrme+RIpWZnvux1+lfXIInnfSTJRM +uAo/ePkoNsM3qQll6uEdhDxOMx8Pq94bCkM3DtI3ObuxWXjKCgUm/n9yetUvPj4U +iYhRUqHpUg== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_bfe_dev.crt b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_bfe_dev.crt new file mode 100644 index 000000000..2164fb602 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_bfe_dev.crt @@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 18 (0x12) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=yingfei-dev-ca + Validity + Not Before: Jan 31 02:55:32 2024 GMT + Not After : Jan 28 02:55:32 2034 GMT + Subject: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=bfe-dev/emailAddress=dev@example.org + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:b2:7f:c1:0c:cd:49:43:d9:99:78:15:4c:5a:52: + a9:a7:bf:d5:eb:92:71:43:e1:37:e5:29:1b:68:f4: + 6f:4c:ea:fa:a4:8a:7c:29:01:2a:fa:7c:81:5b:c8: + eb:a1:40:94:b7:2b:82:e2:00:08:36:84:f0:b7:d2: + 5a:1e:56:97:aa:36:ff:4d:07:49:2d:fe:25:3a:f9: + e9:f1:ad:4e:6e:21:97:a9:f9:a2:a5:ac:82:23:0a: + d2:e0:97:cd:2f:14:b1:f0:8a:a8:e6:c5:97:45:94: + f8:8e:3f:96:66:5b:0b:8c:7c:07:61:18:44:92:f7: + 23:5a:c2:4b:88:58:59:5d:ca:5c:0d:6e:dd:ff:18: + 59:65:df:95:99:e3:3a:36:48:1f:3f:3a:e6:ce:85: + f3:0b:04:5e:92:ed:6f:8e:74:92:e4:37:46:da:5f: + 17:62:9c:82:40:06:fb:29:f8:55:f2:ba:23:75:ca: + 64:c0:45:03:12:bd:f5:17:15:7e:47:d5:bd:30:f2: + 99:ca:6b:e3:07:b0:ae:44:89:1e:10:26:ea:75:df: + 6f:07:b6:47:76:54:47:4f:6c:f6:68:fe:a8:cf:22: + 20:73:e8:19:55:8a:fe:f5:78:e8:51:88:52:80:1f: + 79:d4:c5:ae:8f:d2:2b:f6:41:01:42:01:cf:98:c2: + c9:25 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + EE:E8:68:42:DF:B1:F0:EF:6F:47:51:BD:D4:94:60:1F:05:85:A1:03 + X509v3 Authority Key Identifier: + keyid:0D:99:68:4F:C5:61:55:BC:AC:0F:CD:7D:A0:6F:E8:A8:77:56:D4:3D + + X509v3 Extended Key Usage: + TLS Web Client Authentication + Signature Algorithm: sha256WithRSAEncryption + a9:a6:26:8e:42:61:15:22:ee:fc:b5:e1:e4:6b:dd:ac:f5:15: + 11:39:10:9a:ca:6f:85:fd:cb:90:1c:b2:4f:fe:29:de:b0:e7: + 73:e2:f7:5e:63:8c:7f:c1:7e:75:2e:c9:9d:e9:c2:45:75:f3: + 27:ba:82:94:de:7f:6c:87:0c:5c:71:af:0f:14:00:68:35:f7: + 5a:4a:ff:f5:ef:35:dd:50:72:76:f0:6f:b6:7b:42:33:07:b4: + 24:44:0a:fd:9d:61:9e:44:e8:88:0f:02:76:c6:90:3f:9d:1b: + d8:3b:64:25:2a:a3:39:78:38:bd:20:89:4a:9c:bd:68:38:18: + 4c:cb:20:3a:9b:5b:5f:58:52:86:73:de:85:fe:d6:a1:c6:a7: + 86:b0:96:4b:fa:28:04:ad:5d:85:e8:a1:fc:ca:0f:3c:be:5c: + 90:7e:3e:84:ae:67:ee:9a:72:71:3c:b2:80:45:82:fc:7e:58: + 74:99:42:c5:c3:8a:4a:eb:e1:8b:d5:84:ce:25:aa:a1:75:79: + 94:66:ae:ee:df:30:15:0b:b5:c5:b1:2c:d5:0a:54:78:b6:2e: + 67:29:81:41:f6:16:49:31:96:e7:41:e1:99:6b:27:57:bb:7d: + 76:eb:e4:d5:59:aa:a2:5c:bd:1c:18:2a:fa:9d:28:1a:0b:b6: + bf:7d:58:1a +-----BEGIN CERTIFICATE----- +MIID7jCCAtagAwIBAgIBEjANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yNDAxMzEw +MjU1MzJaFw0zNDAxMjgwMjU1MzJaMH0xCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEUMBIGA1UECwwLeWluZ2ZlaS1k +ZXYxEDAOBgNVBAMMB2JmZS1kZXYxHjAcBgkqhkiG9w0BCQEWD2RldkBleGFtcGxl +Lm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ/wQzNSUPZmXgV +TFpSqae/1euScUPhN+UpG2j0b0zq+qSKfCkBKvp8gVvI66FAlLcrguIACDaE8LfS +Wh5Wl6o2/00HSS3+JTr56fGtTm4hl6n5oqWsgiMK0uCXzS8UsfCKqObFl0WU+I4/ +lmZbC4x8B2EYRJL3I1rCS4hYWV3KXA1u3f8YWWXflZnjOjZIHz865s6F8wsEXpLt +b450kuQ3RtpfF2KcgkAG+yn4VfK6I3XKZMBFAxK99RcVfkfVvTDymcpr4wewrkSJ +HhAm6nXfbwe2R3ZUR09s9mj+qM8iIHPoGVWK/vV46FGIUoAfedTFro/SK/ZBAUIB +z5jCySUCAwEAAaOBkTCBjjAJBgNVHRMEAjAAMCwGCWCGSAGG+EIBDQQfFh1PcGVu +U1NMIEdlbmVyYXRlZCBDZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU7uhoQt+x8O9vR1G9 +1JRgHwWFoQMwHwYDVR0jBBgwFoAUDZloT8VhVbysD819oG/oqHdW1D0wEwYDVR0l +BAwwCgYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADggEBAKmmJo5CYRUi7vy14eRr +3az1FRE5EJrKb4X9y5Acsk/+Kd6w53Pi915jjH/BfnUuyZ3pwkV18ye6gpTef2yH +DFxxrw8UAGg191pK//XvNd1Qcnbwb7Z7QjMHtCRECv2dYZ5E6IgPAnbGkD+dG9g7 +ZCUqozl4OL0giUqcvWg4GEzLIDqbW19YUoZz3oX+1qHGp4awlkv6KAStXYXoofzK +Dzy+XJB+PoSuZ+6acnE8soBFgvx+WHSZQsXDikrr4YvVhM4lqqF1eZRmru7fMBUL +tcWxLNUKVHi2LmcpgUH2FkkxludB4ZlrJ1e7fXbr5NVZqqJcvRwYKvqdKBoLtr99 +WBo= +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem new file mode 100644 index 000000000..764aea3ad --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_bfe_dev_prv.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAsn/BDM1JQ9mZeBVMWlKpp7/V65JxQ+E35SkbaPRvTOr6pIp8 +KQEq+nyBW8jroUCUtyuC4gAINoTwt9JaHlaXqjb/TQdJLf4lOvnp8a1ObiGXqfmi +payCIwrS4JfNLxSx8Iqo5sWXRZT4jj+WZlsLjHwHYRhEkvcjWsJLiFhZXcpcDW7d +/xhZZd+VmeM6NkgfPzrmzoXzCwReku1vjnSS5DdG2l8XYpyCQAb7KfhV8rojdcpk +wEUDEr31FxV+R9W9MPKZymvjB7CuRIkeECbqdd9vB7ZHdlRHT2z2aP6ozyIgc+gZ +VYr+9XjoUYhSgB951MWuj9Ir9kEBQgHPmMLJJQIDAQABAoIBAFAKHzupVb/58+o3 +yqv5wx94Uuk2GlnwxIqaezL94GaiO0/K1U/huS7m426P0rDU75qPBTpn/0bLJ9GV +nllaRNnLnYEh0juwaWtfovp+1ttlbseGK9uUVip2cQbKqvQAmKWe14vbcDCAU1Ad +zUgKbUxKVVjBdAZekVjiJNJ3o2L9WPhf/uQo7A1XAJh2DajlTbgvrDM73W+47QuU +X4OHU0FMio6bxupu3OWl1bMrnKhuC4qczZWf2nOpcVQa89rtopuP4ENLJuWkbeGk +YQpNilEclnAa/Noumt/j/6GKC1EEHFsH2CNRRIazcZrsFhkSKc4pn1Y/WI3vj8kZ ++RYnJsUCgYEA6gr0x8suwRTAmVpoPk4XyP1x+eInG7onV5RjgsUtgruYd9naxRHg +2p8PHcv32pDs51Fa+4RldyMd/jec1SscRF5/+VOP9qeoRnaDqUu+uASgEO3OBxbP +JcWovyxRHIQxbYCQtqIr9bdzXw55MBLZou/sBUVTAkrIyPVyjWqZlV8CgYEAwz7L +YyYN615TsrzZKURxMjj94Nmob/NldSLRXaR3Ax7/ABtEOA685cwQxq7ONdkJTMIA +uR8u2GHZSzGiWnehuF6Zp7Xs71a57eFbs3ueZvvEba4Dff7hl7Y4tTlwrKndKjvP +J/5a2Ol8siQcRWAXHOdzggEHMSZ/sB4hWswly/sCgYEAhLRBpyemEwTZUBrbELjm +86gBgFajJi2fMSGKaxOygnYsNYjpauSAQnX99D87Aks6iM6wb/zaK3tV/lc6LgSL +uph6p7yh3JGj8JAyh0PTmDPHLtIoCAz+18QDsqJGO40ZGaXUaDn8Aw9J85QZUxDd +Jm4zvalZL+uHfarukRDolLECgYBUiupS4nWAh3XCnZeDEQna72avaFBROZmjIRJ7 +c+28wj009JmTlH4jGzvgbG0KUBKA1Div8Fq+g5AtyS498jNqvDvYrSQNdwZHhR/K +Fis++KHTxFfqxOU2Zkcj4d1yRpNn6EIJVVBNQL0n/g7n03XupCIWFw/gLoV343QZ +9vAe5QKBgA8ml59z1w3eUooc0yGfLhihXqCmM3IU006bFbODA30fBU4QKrHO8+Yx +Xbz9bi/1QLagLG6FzYQAkOjBlEBt5XLayYvwSb8xvWsm5A3vzTAbMFDOsDPEmRoH +dWtQccJcygOuK+PZtoZnNoJciNO5c9dZWD3xtIiURmX/kVtWrf6N +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_san_example.org.crt b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_san_example.org.crt new file mode 100644 index 000000000..78f3892b4 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_san_example.org.crt @@ -0,0 +1,85 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 15 (0xf) + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei-dev, CN=yingfei-dev-ca + Validity + Not Before: Dec 7 09:57:42 2023 GMT + Not After : Dec 4 09:57:42 2033 GMT + Subject: C=cn, ST=beijing, O=yingfei-dev, OU=yingfei, CN=dev-test + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:d6:47:b0:17:69:91:4c:d0:66:c9:25:2e:38:f2: + 84:27:e2:7c:38:cc:04:b9:0c:8e:3d:cc:ef:4b:c5: + 35:2b:c1:82:d8:41:fc:25:c8:24:f2:e0:25:aa:f9: + 76:3c:e2:b2:50:fb:2d:ec:4e:16:d4:c1:da:1e:e3: + 51:9f:9d:c9:72:f9:cb:cc:14:9c:c1:82:c9:76:1f: + 98:dd:0e:b9:8a:20:d6:ab:f2:a6:f9:2f:23:81:f3: + af:e4:47:0c:55:95:94:de:ed:f6:a5:24:ee:32:e7: + 6b:79:e1:42:6f:a4:07:b2:95:1d:f5:a9:8e:60:42: + 70:42:bd:e2:30:18:68:74:52:32:98:a9:81:da:d8: + c6:6f:5e:1d:ce:79:b6:f3:ec:4f:ed:7d:22:57:d2: + 14:d0:fb:f2:50:d4:80:3b:89:ed:77:fd:45:6c:e6: + 52:6b:0a:52:71:ac:59:c8:d5:25:f5:40:03:fb:51: + b0:11:a7:00:79:d9:d8:4f:00:43:96:68:44:29:41: + dc:d2:cc:91:c8:61:95:41:4d:0e:66:5a:b5:15:67: + 3e:8a:6f:29:df:1c:8a:6f:ee:9e:97:9c:9e:69:71: + d3:34:52:75:e9:ea:e7:51:77:23:98:46:ca:47:a2: + d3:d3:97:03:41:4b:e3:33:11:72:2d:af:bf:2b:3e: + b3:51 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + OpenSSL Generated Certificate + X509v3 Subject Key Identifier: + 5A:27:32:9D:E7:36:24:A3:C1:DC:2F:95:80:C5:CF:0C:85:E8:E6:AF + X509v3 Authority Key Identifier: + keyid:0D:99:68:4F:C5:61:55:BC:AC:0F:CD:7D:A0:6F:E8:A8:77:56:D4:3D + + X509v3 Subject Alternative Name: + DNS:example.org, DNS:www.example.org, DNS:example.com, DNS:*.example.com, IP Address:127.0.0.1, IP Address:192.168.0.100 + Signature Algorithm: sha256WithRSAEncryption + 1e:e8:e8:8a:ad:a8:0e:fc:c9:82:00:a1:ab:30:3c:a5:b9:dc: + d6:fb:86:ad:30:52:7f:61:be:90:a6:b8:56:bb:f1:0b:e6:39: + 38:65:09:6b:da:83:f7:65:ff:c4:21:de:b4:9e:8b:bd:1e:1c: + d1:d5:94:b8:18:79:f2:d0:06:51:39:67:13:40:3b:73:5b:cb: + ea:de:c1:19:76:f8:7b:0f:15:51:61:49:fb:98:f7:ea:4f:fc: + c2:fb:a7:f4:3c:48:64:14:79:b5:78:5b:20:10:b5:7a:2d:4c: + 04:51:60:ec:20:10:19:26:5f:e2:fd:32:59:67:e9:3f:48:8d: + f5:52:12:01:81:2c:c0:e5:72:cd:7d:0a:eb:7a:05:df:a0:77: + b9:ba:9a:7d:d1:4b:6a:44:e4:2d:98:af:bd:77:2b:f5:ef:26: + 4b:75:b3:97:d0:3a:bc:07:21:ef:71:92:30:fe:a2:79:e5:56: + d7:7e:c2:f3:57:ab:d7:de:fc:97:ed:20:0c:9a:cb:c5:5d:00: + 3b:61:29:e8:00:d4:39:e0:f2:4e:a4:03:c2:12:52:ff:e7:78: + f9:f7:c0:12:dc:36:a4:05:a2:f0:6b:47:e2:21:3d:a2:e1:a1: + 91:c7:ac:8f:b8:ae:58:65:e0:2b:57:80:eb:77:2d:48:ef:e6: + fb:b9:e1:20 +-----BEGIN CERTIFICATE----- +MIIEBzCCAu+gAwIBAgIBDzANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJjbjEQ +MA4GA1UECAwHYmVpamluZzEUMBIGA1UECgwLeWluZ2ZlaS1kZXYxFDASBgNVBAsM +C3lpbmdmZWktZGV2MRcwFQYDVQQDDA55aW5nZmVpLWRldi1jYTAeFw0yMzEyMDcw +OTU3NDJaFw0zMzEyMDQwOTU3NDJaMFoxCzAJBgNVBAYTAmNuMRAwDgYDVQQIDAdi +ZWlqaW5nMRQwEgYDVQQKDAt5aW5nZmVpLWRldjEQMA4GA1UECwwHeWluZ2ZlaTER +MA8GA1UEAwwIZGV2LXRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQDWR7AXaZFM0GbJJS448oQn4nw4zAS5DI49zO9LxTUrwYLYQfwlyCTy4CWq+XY8 +4rJQ+y3sThbUwdoe41Gfncly+cvMFJzBgsl2H5jdDrmKINar8qb5LyOB86/kRwxV +lZTe7falJO4y52t54UJvpAeylR31qY5gQnBCveIwGGh0UjKYqYHa2MZvXh3Oebbz +7E/tfSJX0hTQ+/JQ1IA7ie13/UVs5lJrClJxrFnI1SX1QAP7UbARpwB52dhPAEOW +aEQpQdzSzJHIYZVBTQ5mWrUVZz6KbynfHIpv7p6XnJ5pcdM0UnXp6udRdyOYRspH +otPTlwNBS+MzEXItr78rPrNRAgMBAAGjgc0wgcowCQYDVR0TBAIwADAsBglghkgB +hvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYDVR0OBBYE +FFonMp3nNiSjwdwvlYDFzwyF6OavMB8GA1UdIwQYMBaAFA2ZaE/FYVW8rA/NfaBv +6Kh3VtQ9ME8GA1UdEQRIMEaCC2V4YW1wbGUub3Jngg93d3cuZXhhbXBsZS5vcmeC +C2V4YW1wbGUuY29tgg0qLmV4YW1wbGUuY29thwR/AAABhwTAqABkMA0GCSqGSIb3 +DQEBCwUAA4IBAQAe6OiKragO/MmCAKGrMDyludzW+4atMFJ/Yb6QprhWu/EL5jk4 +ZQlr2oP3Zf/EId60nou9HhzR1ZS4GHny0AZROWcTQDtzW8vq3sEZdvh7DxVRYUn7 +mPfqT/zC+6f0PEhkFHm1eFsgELV6LUwEUWDsIBAZJl/i/TJZZ+k/SI31UhIBgSzA +5XLNfQrregXfoHe5upp90UtqROQtmK+9dyv17yZLdbOX0Dq8ByHvcZIw/qJ55VbX +fsLzV6vX3vyX7SAMmsvFXQA7YSnoANQ54PJOpAPCElL/53j598AS3DakBaLwa0fi +IT2i4aGRx6yPuK5YZeArV4Drdy1I7+b7ueEg +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem new file mode 100644 index 000000000..393a79825 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/backend_rs/r_san_example.org_prv.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA1kewF2mRTNBmySUuOPKEJ+J8OMwEuQyOPczvS8U1K8GC2EH8 +Jcgk8uAlqvl2POKyUPst7E4W1MHaHuNRn53JcvnLzBScwYLJdh+Y3Q65iiDWq/Km ++S8jgfOv5EcMVZWU3u32pSTuMudreeFCb6QHspUd9amOYEJwQr3iMBhodFIymKmB +2tjGb14dznm28+xP7X0iV9IU0PvyUNSAO4ntd/1FbOZSawpScaxZyNUl9UAD+1Gw +EacAednYTwBDlmhEKUHc0syRyGGVQU0OZlq1FWc+im8p3xyKb+6el5yeaXHTNFJ1 +6ernUXcjmEbKR6LT05cDQUvjMxFyLa+/Kz6zUQIDAQABAoIBAC4sYGuLGf49Ygix +9FXdHFEj4rSyccoWRIhYoq/nHOAC4NkMzvKtQBj95+ABxVK1XstIdMrYwN6zrva8 +8Re9/mzCGwIs5uJj9ll30Y7A34Y+MUP4E7baS4JzKlG8ZZIDm4K2MFHBtXpOl8A5 +pAE+jVIUA9Kt6LohVuNq21SVzdxSfNYC/+SLqSftkWa/ZsqdkiHM5Hl+fVedh516 +IaLNW5hSthGh5n8dHY5h/AKPjfoq77aYp5/CUtJTC9mYdZu1j/W/pBVTRfOnwLQd +SQ1Xmr7f6q9Vmz+HnajIbFg9hQ54blvtUJ7DnugWxfUcoxf7ue79fnYjOIUOkRWw +8Iid/mECgYEA+5s0p0j+gkNZN5QtVNStoT04+1DqA1O381gczeaiz6Njt/MT0y5W +OpCsILQ70CpEjWAV+f6PDJiesDMxdGV+v2TCqK8ml8GahEczBLnHoAkPWCVf2XOX +oNj/CkZ2kmWufHFR+kcQbeDt1vFFcYUa61hKDFyNjinMW79Qy+PQID0CgYEA2gWd +7thE05sqU7/1MntmVRONKoAgnJmHcfSpWwLyh6E4YX3iKDSgI/9RnAMF39KUUY/O +XFWyIwAM9soeXknVsV/SmCaPeaEDiLHqz98aUEfvdLYMnuR883GgoXc4JrLsLw4z +oSi9lbAZFn0ekJ5L+rSFrY4rz9QZgYZxsLjUXKUCgYAbkaUSU2g3w8Np2J2i9u7T +hQ7SUsphdPHqAxSc5xGd6MxLYqIgeKpQHnwN1VHcfFUonIer7d2kxrBUpDdeBqT9 +ub+ulgqHhFo29ko70VNzUKrSwL2g6Q6LPFutt4zUe7nDvvL5loHRWF0XOTafurL5 +aKIsepO0KRZQU0U6IgszDQKBgFs6ZHaP2mTtFY4L0a75Ab3xu20gRgUhHRLq/H6P +wipMpMnuodaPBr9pU53Dig65D8T9Nq1eUnbgy4vs0T5FCPz6iqWN5RVQ8aieQhIP +WfRj1Wfx0WAfXcWEM2G9ACr5TWj3OVVjNclP8X9+hW6gPky+gv03c0+4gZ+4QRRg +ksPdAoGBAKXVv8L5Qt8rmi+QpF/6DVSeMB6lEevf319UEtyZPiYSBn1JJ9H+3Ddc +TMriXgZrw+PoXNJTAguDeGgzmtye1vraP0cjt7aG6sQ4YtMTnnuja880vK8z4i3Q +HJAi5fI8NvlW92dQBAccgrzFIKpRx+6CHwtCkNrxL2vJmGGzp8BR +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/certs/example.crt b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/certs/example.crt new file mode 100644 index 000000000..931874885 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/certs/example.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEjCCAfqgAwIBAgIJAIAdu56fLE7OMA0GCSqGSIb3DQEBCwUAMBYxFDASBgNV +BAMMC2V4YW1wbGUub3JnMCAXDTIwMDYxMDEwMzM0NFoYDzIxMjAwNTE3MTAzMzQ0 +WjAWMRQwEgYDVQQDDAtleGFtcGxlLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBALv+LV1aWIlcK9rI7IuRS8SCusqBnoyJec/ErKiA2gbfgZ/YS73L +Zud84yp45AIqauzcI5q+hrkmsRZ7CKqDzrG+jHavW7jF+0laetJwRt26AcQcOtQD +2ik2O+Dl1WHAFn4vUAQxb+Xz6WfSaQN0QfM74z06XUDDsr7g7+NYtMzhf98SJSoK +ne/dVKJ3Bc6e6tvhnCRwPtix4ektEodK6WeNHYxwJ6wSZ8cRLzdxgjdD/4OGfFuj +dn8zbOi3SQt5ZqVbcDHUTzp5t0G8EoxnzotHhhzjSAmsypySqZXaxl3oX8aYUkFn +fCdg+WBXo5pOiNfoWh/D5bnIXWGp52yoy+kCAwEAAaNhMF8wHQYDVR0lBBYwFAYI +KwYBBQUHAwIGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFIH+0G3eCswQHbN06kvI80M3 +tNH9MB0GA1UdDgQWBBSxLHQE7gOEyfeSNc5uIO/G/rgjpzANBgkqhkiG9w0BAQsF +AAOCAQEAlGm5RwQ79xmLh3rj+5UViCSgsIuMcuhgIT4zogpo9S4uwXMqinrJhzRk +Oc2tb3y06XTAq1lMH2+58tqndAu8ni/UBz3OSghk2CTnZ1vxxXOd3CtQu4ypMq+k +qW0Umdrkk5TeAODNbrCy4c6vpICkQOljnRFWnDYu3aQ3JvaWZ/nObN7C72Lgpjfb +RfLXGmLsBCEIr028f9hpoeRCXoetUY2CiC2boAHR+cO6Jpvex4Jv5yYDpNKac52n +LLC8Cq5ozhOZNOSV6X9FpEca3rdhVUb0VgoNDCPZDdpO1PDJYCN9fUC8KUs+dsOh +SYGpliBaNsztoiJs1q5SkMQrMwmMmg== +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/certs/example.key b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/certs/example.key new file mode 100644 index 000000000..b21c2f08d --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/certs/example.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAu/4tXVpYiVwr2sjsi5FLxIK6yoGejIl5z8SsqIDaBt+Bn9hL +vctm53zjKnjkAipq7Nwjmr6GuSaxFnsIqoPOsb6Mdq9buMX7SVp60nBG3boBxBw6 +1APaKTY74OXVYcAWfi9QBDFv5fPpZ9JpA3RB8zvjPTpdQMOyvuDv41i0zOF/3xIl +Kgqd791UoncFzp7q2+GcJHA+2LHh6S0Sh0rpZ40djHAnrBJnxxEvN3GCN0P/g4Z8 +W6N2fzNs6LdJC3lmpVtwMdRPOnm3QbwSjGfOi0eGHONICazKnJKpldrGXehfxphS +QWd8J2D5YFejmk6I1+haH8PluchdYannbKjL6QIDAQABAoIBAEUBL7WsjAMfihls +1ycD1kPzmIzstz3u2H+jOZ1AbsdHE1WRF3w7RTKDbP8SEN+aolT/GTKb7OfZg/c0 +giHU7/Hed8C47XoNcgei5qKIA/svY6aQlidsoo+uEJykwIZ488itpTlkzCYkOfCa +E2HpMqwNt4OqAMDdFKdr+aIB1Zu+KPBxW23WD9wEWAbe5LA4YnRF9kT4YZ6y9mce +dGaIf39VtBlrGMmvoU0LE9B79nyuebGi0svW6QDarBqaDrnM/N3fXgL1kk/gVfan +/xs6EA4qPxA5G4h+enYrIlZL0CbSj60nYElo+Z5nRdBaRdCF/bpXOLyK/kXWLUM0 +f2HTK+ECgYEA5cVcpJtczxEaxoaEUbppsW1LCrTjJDGTKJ63G7/lwqCxJeCHN185 +nnckHOW2287e19bu9aUmKJgRq5s1rXnT+MnCl/hQnfaMrKOKtzE+t7/zsc9+LuAr +pwJrtZ9Dcnwrk8NOE0fPjW5XpDCSoEOo7JWZmGTVlpabOgNkjocfp+sCgYEA0XPt +ZPt3F0wyzgLYRhgnvp5CV8SzQmulsW+ytnL5eiAcNSXqni3wQHN3PGxLInEyQwBQ +/M8TQpUbqGMmahCK4ZxMAwXMrpF0mVB8jfoYMou1FSYPlUV+CvLjWcTkZB1Nirez +VFXdtfHP0mx4PbYK2qjB03u4pPHAN8kuayIf2nsCgYAbv/FHZAgabfto3Jggcr4P +Ep8MhPolxeL69egxbsSl89hRNcO+2T5ROBxhbRDfjSV2tduYSUDJiEwiCJW8BMmn +814QEopR+ZPVyc6X/1eOw5z/7YpUyPgcrHsrrTdtHTf6GY1VYMfdUeU9zCv5NRKy +uAKb2Bm/nSLUJ9K+L+2PzwKBgQDRQgT3UtTUjehkMitpPFDY/LxDe92simfsMjBW +X+Anx1TnNI6GolbZzYJe98LJElao4fQH38raRqZvQT/rz8MxTDoU+wJXljLryaHn +Jupt9W5hRrli5R7cSXYjBbc43p3N7WJY68CqOoDrNjubS/jkJJ4hcAY1pOHp2jFq +D5nLaQKBgAysU6O5kJ8yKxhbZflb42MqKCFBGrbRnbYx14PAEZOaRhzxehpppQmx +RLbn/z1Uh5Ms28ipxA+vnhyM3FcU5lKboaFyWJeuNslw0FxEcIai6hL6UkDznS4G +aqyzUjpG5Chg0x18xWYCbiGJwjZ9BWhtH+jojm856QHGzeQJWVoo +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/client_ca/example_ca.crt b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/client_ca/example_ca.crt new file mode 100644 index 000000000..b0fa2fa28 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/client_ca/example_ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDATCCAemgAwIBAgIJAMsPuHg4mqnaMA0GCSqGSIb3DQEBCwUAMBYxFDASBgNV +BAMMC2V4YW1wbGUub3JnMCAXDTIwMDYxMDEwMzMwNloYDzIxMjAwNTE3MTAzMzA2 +WjAWMRQwEgYDVQQDDAtleGFtcGxlLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBAL72D2gOnJN9Zvo9KjruwM1EsFe3xZRJ0NvZ5bHd6+5jhlgCAhQ+ +AGb7ufEiYOi2JWHl2Bkq0iVrp+zv0RLdq0oVjX+OG5H2yWbnC7ifbNjir93LX0un +tIqv5CIbExDSBRkufxfV37yjXdrcMqYSbD2Kw3PfAbWs1Dego8fRz8QAp5+LCvW2 +BZZyYi6JzhCAUW1+8OQPyzOhB50eSJiS5xgVA7wkwmYeVUpHqU8sv4VzjM3bmUc7 +1mPLlnRVIqScrqYgQ9Ou21vZebOJ8+ckVL8O3XHhMZlssBbWiBFZZnaNWbzcEI90 +oiW4YAQ5t7gaXuCVvaiNvq2VZknarR6AxcsCAwEAAaNQME4wHQYDVR0OBBYEFIH+ +0G3eCswQHbN06kvI80M3tNH9MB8GA1UdIwQYMBaAFIH+0G3eCswQHbN06kvI80M3 +tNH9MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJtHx8ibLp+CYDR1 +ZVeQ3qg4OeRL0q21+2EgBJq6zOUt/9SxThA80aXJ8CYH9dnCW+fOnpEk1xFWxtXc +FwSLsnqAdwOJaaQWoMzhyqjZV5x5G9+MW5FzGGOdes2md2Z+tAwMoV9TVtxZkbKy +mC2tDJdvgLgt9/YcbUcZPDbyZojdZ+UbATm+Lro9dhTXt91vsAgz5QA9e08rQVkF +pc9+ZQ5zxBsoblQ+ozPOWOdV4zJVx+wQsAnOG2qU0yVQAscGsTo4wnzFrAU54fO7 +Lh4cOrY0P1/o65yiSzwK7f0jwBeT/jEfMOrJ7pPo7doUov0iVj0SZyTM3HFa2Mzj +2zGTk0o= +-----END CERTIFICATE----- diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/client_ca/example_ca.key b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/client_ca/example_ca.key new file mode 100644 index 000000000..4f7ad13b1 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/client_ca/example_ca.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAvvYPaA6ck31m+j0qOu7AzUSwV7fFlEnQ29nlsd3r7mOGWAIC +FD4AZvu58SJg6LYlYeXYGSrSJWun7O/REt2rShWNf44bkfbJZucLuJ9s2OKv3ctf +S6e0iq/kIhsTENIFGS5/F9XfvKNd2twyphJsPYrDc98BtazUN6Cjx9HPxACnn4sK +9bYFlnJiLonOEIBRbX7w5A/LM6EHnR5ImJLnGBUDvCTCZh5VSkepTyy/hXOMzduZ +RzvWY8uWdFUipJyupiBD067bW9l5s4nz5yRUvw7dceExmWywFtaIEVlmdo1ZvNwQ +j3SiJbhgBDm3uBpe4JW9qI2+rZVmSdqtHoDFywIDAQABAoIBAD60bbqtkZycwQPK +seNIIudEduNW5PocgwiuNE6DoMVWyPZ9MlGTSm6GmjgkIc5IgV30K1GYTgkboLic +xvp675QUH7KS51q2vsubcq3dK9DMHxOlhFVDbHVd7HuGiGwtip8KNZGOGTnIKzmC +tN7zjbdnqWaTA+y0I7tgdGdY7fBd9Rzgaq+OlPq2u33HvWHvlG/7PfpZuXB5YLgd +m04l7LJ7ikhIjycg7j27v/4c6xCiH5jMJKsZ+nfsQ0kEEo9DkhcKInK+wHsMzKsH +Cy3AdlE0IRsbxRAoMumVs2g5u90m3zBPkRrNdZ2Ni7BesnhxbkIqvb4SfpxKyuhK +fADfZgECgYEA7SUIS2gII0TGjXh0h16d+eoLVOz0eVpgF3XXxmgtAPu10dqxVEC2 +j5FSBCgZhqZ3axVotP71c2mT+hF+Mqy4TLMfA/B9jKLXjZlPbg4EcAgI7tALskwz +Bk5BkX0k825bU9P0j+AlpLx6/ztHr2N9/cKZfqQVrO9t+FRusvAHkHsCgYEAziT8 +F30Ch2s6IJngCj5jH164iN2CoFXjqPNVgRj45gLE3zrf1R7u2JTeEhjWNLZ6IWZ3 +G/bT7eYm6x8u7LFlORdnWKsHlftGu0igRyvIGcxoHgjXlsLidBaEP+HlOLUtTumu +MfQJUozLcrOBIV6m9VhPnSDTeCg/tOqy68V2pvECgYAUYgd5e8KfTW0Hgd/6Nq67 +aVt5/DfzKkpyGcXnHtMnb3ssQ3DUfg9y/ZmgE9ZF1Y8UHC34yKVOOzfl2ZUQQ/o/ +VXIIA6a27NQ8Ln4+RmQpQPeLl0Q6GgSUuSs3lxsS9VxSMzilGS4DH9QulejOcW3F +3vEUioP2bkn0e0VcifcMewKBgAG1Pr13FLFIiye//qI3GB0nbMH9i9qGO6entHqo +WU+WkEkFNNuQMQxsV1axC/1N0b87GRuLNQBQmtvx2zKs2Zjaf8m1SQ/OECz3EhTk +4PiNwAMXsamXHcc2dIwO9BY/MgvoVcAmNHmRnxHpONWs8hcwTyCPKBFjy/tUwny/ +mxcRAoGAcNRxLZlyRqmQ6zGf41GK4ZIR9gix0L6Km49S1maGFmcbctOR2GcQN8Eo +f38rkrFBfBfuFSGzghJiXDvKORg9r3V/bzSKcXkprJra6hzn5vn+t7wurjzaJUlK +zUW5dl3SU2bC4MK+X7bwqf9jm9b7FXSt4p1xly8Uh/Mufwi7zOM= +-----END RSA PRIVATE KEY----- diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/server_cert_conf.data b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/server_cert_conf.data new file mode 100644 index 000000000..49f228531 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/server_cert_conf.data @@ -0,0 +1,12 @@ +{ + "Version": "init version", + "Config": { + "Default": "example.org", + "CertConf": { + "example.org": { + "ServerCertFile": "tls_conf/certs/example.crt", + "ServerKeyFile" : "tls_conf/certs/example.key" + } + } + } +} diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/session_ticket_key.data b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/session_ticket_key.data new file mode 100644 index 000000000..b3d2356b0 --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/session_ticket_key.data @@ -0,0 +1,4 @@ +{ + "Version": "init version", + "SessionTicketKey": "08a0d852ef494143af613ef32d3c39314758885f7108e9ab021d55f422a454f7c9cd5a53978f48fa1063eadcdc06878f" +} diff --git a/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/tls_rule_conf.data b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/tls_rule_conf.data new file mode 100644 index 000000000..66e7b5dbb --- /dev/null +++ b/tests/integration/implementation/scenario-SC10-ai-key-session-affinity/testdata/tls_conf/tls_rule_conf.data @@ -0,0 +1,20 @@ +{ + "Version": "12", + "DefaultNextProtos": ["http/1.1"], + "Config": { + "example_product": { + "VipConf": [ + "10.199.4.14" + ], + "SniConf": ["example.org"], + "CertName": "example.org", + "NextProtos": [ + "h2;rate=100;isw=65535;mcs=200;level=0", + "http/1.1" + ], + "Grade": "C", + "ClientAuth": false, + "ClientCAName": "example_ca" + } + } +} diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-01-\345\274\200\345\220\257\344\272\262\345\222\214\346\200\247\346\227\266\345\220\214\344\270\200ClientKeyId\345\221\275\344\270\255\345\220\214\344\270\200Key.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-01-\345\274\200\345\220\257\344\272\262\345\222\214\346\200\247\346\227\266\345\220\214\344\270\200ClientKeyId\345\221\275\344\270\255\345\220\214\344\270\200Key.md" new file mode 100644 index 000000000..17525cece --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-01-\345\274\200\345\220\257\344\272\262\345\222\214\346\200\247\346\227\266\345\220\214\344\270\200ClientKeyId\345\221\275\344\270\255\345\220\214\344\270\200Key.md" @@ -0,0 +1,59 @@ +# TC-01 开启亲和性时同一 ClientKeyId 命中同一 Key + +## 用例编号与名称 + +TC-01 开启亲和性时同一 ClientKeyId 命中同一 Key + +## 所属场景 + +SC10 同 cluster 多 API-Key 会话保持 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证 `AIConf.KeyPolicy.SessionAffinity = true` 时,同一 `ClientKeyId` 的连续请求会被绑定到同一个 Provider Key,从而提升 Provider 侧缓存命中率。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动。 +3. mock 后端 `cluster_session_affinity` 已启动,返回 200。 +4. 临时 BFE 配置已生成并加载,包含: + - `AIConf.Keys`:`key-a`、`key-b`、`key-c` + - `KeyPolicy.SessionAffinity = true` + - `KeyPolicy.SessionAffinityRedisPrefix = "bfe:ai:key_affinity"` +5. `ai_route.data` 中 `apikey_ak_session_affinity` 命中 `cluster_session_affinity`。 +6. `ak_session_affinity` 已绑定到该路由表,并在 `token_rule.data` 中对应 `key_id = session_affinity_key_id`。 + +## 配置构造 + +- `cluster_session_affinity.AIConf`: + - `Keys`: `[{"Name":"key-a","Key":"sk-key-a","Weight":50},{"Name":"key-b","Key":"sk-key-b","Weight":30},{"Name":"key-c","Key":"sk-key-c","Weight":20}]` + - `KeyPolicy.SessionAffinity`: `true` + - `KeyPolicy.SessionAffinityTTL`: `300` + - `KeyPolicy.SessionAffinityRedisPrefix`: `"bfe:ai:key_affinity"` + +## BFE 请求 + +连续发送 50 次 POST 请求: + +| Host | Path | Authorization | Body | +|------|------|---------------|------| +| `session-affinity.example.org` | `/v1/chat/completions` | `Bearer ak_session_affinity` | `{"model":"gpt-4"}` | + +## 预期结果 + +- 50 次请求全部返回 200。 +- mock 后端收到的 `Authorization` 头只有一种 Provider Key。 +- Redis 中存在绑定 `bfe:ai:key_affinity:cluster_session_affinity:session_affinity_key_id`。 + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-02-\345\205\263\351\227\255\344\272\262\345\222\214\346\200\247\346\227\266\350\257\267\346\261\202\345\210\206\346\225\243\345\210\260\345\244\232\344\270\252Key.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-02-\345\205\263\351\227\255\344\272\262\345\222\214\346\200\247\346\227\266\350\257\267\346\261\202\345\210\206\346\225\243\345\210\260\345\244\232\344\270\252Key.md" new file mode 100644 index 000000000..f22467ce2 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-02-\345\205\263\351\227\255\344\272\262\345\222\214\346\200\247\346\227\266\350\257\267\346\261\202\345\210\206\346\225\243\345\210\260\345\244\232\344\270\252Key.md" @@ -0,0 +1,53 @@ +# TC-02 关闭亲和性时请求分散到多个 Key + +## 用例编号与名称 + +TC-02 关闭亲和性时请求分散到多个 Key + +## 所属场景 + +SC10 同 cluster 多 API-Key 会话保持 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证 `AIConf.KeyPolicy.SessionAffinity = false` 时,请求按加权随机分发到多个 Provider Key,不建立会话绑定。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动。 +3. mock 后端 `cluster_session_affinity` 已启动,返回 200。 +4. 临时 BFE 配置已生成并加载,包含: + - `AIConf.Keys`:`key-a`、`key-b`、`key-c` + - `KeyPolicy.SessionAffinity = false` + +## 配置构造 + +- `cluster_session_affinity.AIConf`: + - `Keys`: `[{"Name":"key-a","Key":"sk-key-a","Weight":50},{"Name":"key-b","Key":"sk-key-b","Weight":30},{"Name":"key-c","Key":"sk-key-c","Weight":20}]` + - `KeyPolicy.SessionAffinity`: `false` + +## BFE 请求 + +连续发送 200 次 POST 请求: + +| Host | Path | Authorization | Body | +|------|------|---------------|------| +| `session-affinity.example.org` | `/v1/chat/completions` | `Bearer ak_session_affinity` | `{"model":"gpt-4"}` | + +## 预期结果 + +- 200 次请求全部返回 200。 +- mock 后端收到的 Provider Key 种类数 ≥ 2。 + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-03-\344\272\262\345\222\214Key\350\277\224\345\233\236429\345\220\216\350\275\256\346\215\242\345\271\266\351\207\215\346\226\260\347\273\221\345\256\232.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-03-\344\272\262\345\222\214Key\350\277\224\345\233\236429\345\220\216\350\275\256\346\215\242\345\271\266\351\207\215\346\226\260\347\273\221\345\256\232.md" new file mode 100644 index 000000000..298d49983 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-03-\344\272\262\345\222\214Key\350\277\224\345\233\236429\345\220\216\350\275\256\346\215\242\345\271\266\351\207\215\346\226\260\347\273\221\345\256\232.md" @@ -0,0 +1,60 @@ +# TC-03 亲和 Key 返回 429 后轮换并重新绑定 + +## 用例编号与名称 + +TC-03 亲和 Key 返回 429 后轮换并重新绑定 + +## 所属场景 + +SC10 同 cluster 多 API-Key 会话保持 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证当已绑定的亲和 Key 返回 429 时,BFE 会切换到其他 Provider Key,并更新 Redis 中的会话绑定。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动。 +3. mock 后端 `cluster_session_affinity` 已启动,初始返回 200。 +4. 临时 BFE 配置已生成并加载,`SessionAffinity = true`。 +5. `ak_session_affinity` 已建立 token 与路由绑定。 + +## 配置构造 + +- `cluster_session_affinity.AIConf`: + - `Keys`: `[{"Name":"key-a","Key":"sk-key-a","Weight":50},{"Name":"key-b","Key":"sk-key-b","Weight":30},{"Name":"key-c","Key":"sk-key-c","Weight":20}]` + - `KeyPolicy.SessionAffinity`: `true` + - `KeyPolicy.SessionAffinityPenaltyEnable`: `true` + +## BFE 请求 + +第一次请求与后续请求使用相同构造: + +| Host | Path | Authorization | Body | +|------|------|---------------|------| +| `session-affinity.example.org` | `/v1/chat/completions` | `Bearer ak_session_affinity` | `{"model":"gpt-4"}` | + +执行步骤: + +1. 发送 1 次请求,建立绑定并记录最终 Provider Key `K1`; +2. 修改 mock 后端:当 `Authorization == K1` 时返回 429,否则返回 200; +3. 再发送 20 次请求。 + +## 预期结果 + +- 后续 20 次请求全部返回 200。 +- 至少有一次成功请求使用了非 `K1` 的 Provider Key。 +- Redis 绑定更新为新的 Provider Key。 + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-04-\345\215\225Key-cluster\350\267\263\350\277\207Redis\347\273\221\345\256\232.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-04-\345\215\225Key-cluster\350\267\263\350\277\207Redis\347\273\221\345\256\232.md" new file mode 100644 index 000000000..a0acaaa18 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-04-\345\215\225Key-cluster\350\267\263\350\277\207Redis\347\273\221\345\256\232.md" @@ -0,0 +1,52 @@ +# TC-04 单 Key cluster 跳过 Redis 绑定 + +## 用例编号与名称 + +TC-04 单 Key cluster 跳过 Redis 绑定 + +## 所属场景 + +SC10 同 cluster 多 API-Key 会话保持 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证当 cluster 下只配置一个 API-Key 时,BFE 完全不读写 Redis 绑定,避免冗余的 Redis 访问。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动。 +3. mock 后端 `cluster_session_affinity` 已启动,返回 200。 +4. 临时 BFE 配置已生成并加载,`AIConf.Keys` 仅包含一个 Key。 + +## 配置构造 + +- `cluster_session_affinity.AIConf`: + - `Keys`: `[{"Name":"key-a","Key":"sk-key-a","Weight":100}]` + - `KeyPolicy.SessionAffinity`: `true` + +## BFE 请求 + +连续发送 10 次 POST 请求: + +| Host | Path | Authorization | Body | +|------|------|---------------|------| +| `session-affinity.example.org` | `/v1/chat/completions` | `Bearer ak_session_affinity` | `{"model":"gpt-4"}` | + +## 预期结果 + +- 10 次请求全部返回 200。 +- mock 后端收到的 `Authorization` 头全部为 `Bearer sk-key-a`。 +- Redis 中不存在 `bfe:ai:key_affinity:cluster_session_affinity:session_affinity_key_id`。 + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-05-\344\270\215\345\220\214ClientKeyId\347\213\254\347\253\213\347\273\221\345\256\232.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-05-\344\270\215\345\220\214ClientKeyId\347\213\254\347\253\213\347\273\221\345\256\232.md" new file mode 100644 index 000000000..420186937 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-05-\344\270\215\345\220\214ClientKeyId\347\213\254\347\253\213\347\273\221\345\256\232.md" @@ -0,0 +1,58 @@ +# TC-05 不同 ClientKeyId 独立绑定 + +## 用例编号与名称 + +TC-05 不同 ClientKeyId 独立绑定 + +## 所属场景 + +SC10 同 cluster 多 API-Key 会话保持 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证不同 `ClientKeyId` 在 Redis 中有独立的 Key 绑定,各自命中一个 Provider Key,互不干扰。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动。 +3. mock 后端 `cluster_session_affinity` 已启动,返回 200。 +4. 临时 BFE 配置已生成并加载,`SessionAffinity = true`。 +5. `token_rule.data` 中包含两个 token: + - `ak_session_affinity` -> `key_id = session_affinity_key_id` + - `ak_session_affinity_2` -> `key_id = session_affinity_key_id_2` +6. `ai_route.data` 中两个 apiKey 均绑定到同一路由表 `apikey_ak_session_affinity`。 + +## 配置构造 + +- `cluster_session_affinity.AIConf`: + - `Keys`: `[{"Name":"key-a","Key":"sk-key-a","Weight":50},{"Name":"key-b","Key":"sk-key-b","Weight":30},{"Name":"key-c","Key":"sk-key-c","Weight":20}]` + - `KeyPolicy.SessionAffinity`: `true` + +## BFE 请求 + +| 分组 | Host | Path | Authorization | Body | 次数 | +|------|------|------|---------------|------|------| +| 1 | `session-affinity.example.org` | `/v1/chat/completions` | `Bearer ak_session_affinity` | `{"model":"gpt-4"}` | 20 | +| 2 | `session-affinity.example.org` | `/v1/chat/completions` | `Bearer ak_session_affinity_2` | `{"model":"gpt-4"}` | 20 | + +## 预期结果 + +- 40 次请求全部返回 200。 +- 分组 1 的 20 次请求只使用一个 Provider Key。 +- 分组 2 的 20 次请求只使用一个 Provider Key。 +- Redis 中存在两个绑定: + - `bfe:ai:key_affinity:cluster_session_affinity:session_affinity_key_id` + - `bfe:ai:key_affinity:cluster_session_affinity:session_affinity_key_id_2` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-06-BFE\351\207\215\345\220\257\345\220\216\347\273\221\345\256\232\344\277\235\346\214\201.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-06-BFE\351\207\215\345\220\257\345\220\216\347\273\221\345\256\232\344\277\235\346\214\201.md" new file mode 100644 index 000000000..d622a2129 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/TC-06-BFE\351\207\215\345\220\257\345\220\216\347\273\221\345\256\232\344\277\235\346\214\201.md" @@ -0,0 +1,59 @@ +# TC-06 BFE 重启后绑定保持 + +## 用例编号与名称 + +TC-06 BFE 重启后绑定保持 + +## 所属场景 + +SC10 同 cluster 多 API-Key 会话保持 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证 Redis 中的 `ClientKeyId -> Provider Key` 绑定在 BFE 进程重启后仍然有效,保证会话亲和性跨进程保持。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动。 +3. mock 后端 `cluster_session_affinity` 已启动,返回 200。 +4. 临时 BFE 配置已生成并加载,`SessionAffinity = true`。 + +## 配置构造 + +- `cluster_session_affinity.AIConf`: + - `Keys`: `[{"Name":"key-a","Key":"sk-key-a","Weight":50},{"Name":"key-b","Key":"sk-key-b","Weight":30},{"Name":"key-c","Key":"sk-key-c","Weight":20}]` + - `KeyPolicy.SessionAffinity`: `true` + - `KeyPolicy.SessionAffinityTTL`: `300` + +## BFE 请求 + +| 阶段 | Host | Path | Authorization | Body | 次数 | +|------|------|------|---------------|------|------| +| 重启前 | `session-affinity.example.org` | `/v1/chat/completions` | `Bearer ak_session_affinity` | `{"model":"gpt-4"}` | 10 | +| 重启后 | `session-affinity.example.org` | `/v1/chat/completions` | `Bearer ak_session_affinity` | `{"model":"gpt-4"}` | 10 | + +执行步骤: + +1. 启动 BFE,发送 10 次请求,记录最终绑定的 Provider Key `K1`; +2. 停止 BFE 进程,但保持 mock 后端与 Redis 运行; +3. 使用同一配置重新启动 BFE; +4. 再发送 10 次请求。 + +## 预期结果 + +- 重启前 10 次请求全部返回 200,且均使用同一 Provider Key `K1`。 +- 重启后 10 次请求全部返回 200,且均继续使用 `K1`。 +- Redis 中绑定 `bfe:ai:key_affinity:cluster_session_affinity:session_affinity_key_id` 的值为 `K1` 对应的名字(如 `key-a`)。 + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/\345\234\272\346\231\257\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/\345\234\272\346\231\257\350\257\264\346\230\216.md" new file mode 100644 index 000000000..3d8d3df10 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC10-\345\220\214cluster\345\244\232API-Key\344\274\232\350\257\235\344\277\235\346\214\201/\345\234\272\346\231\257\350\257\264\346\230\216.md" @@ -0,0 +1,228 @@ +# SC10 同 cluster 多 API-Key 会话保持 + +## 1. 场景背景与目的 + +当同一个 cluster 下配置多个 Provider API-Key 时,如果同一客户端会话的连续请求被随机分发到不同 Key,会导致 Provider 侧的缓存命中率下降。`bfe` 通过 `AIConf.KeyPolicy.SessionAffinity` 提供基于 `ClientKeyId` 的会话级 Key 亲和性: + +- 以 `ClientKeyId`(来自 `mod_ai_token_auth` 的 `key_id`)作为会话标识; +- 在 Redis 中维护 `ClientKeyId -> 选中 Provider Key` 的绑定; +- 当亲和 Key 返回 429/401/403 时,自动轮换并重新绑定; +- 单 Key cluster 直接短路,避免访问 Redis。 + +本场景验证: + +- 开启亲和性时,同一 `ClientKeyId` 的多次请求命中同一 Provider Key; +- 关闭亲和性时,请求按加权随机分发到多个 Key; +- 亲和 Key 返回 429 后,能切换到其他 Key 并更新绑定; +- 单 Key cluster 不生成 Redis 绑定; +- 不同 `ClientKeyId` 有独立绑定; +- BFE 重启后,Redis 绑定仍然保持。 + +## 2. 运行模式 + +- **单组件模式**:仅启动真实 `bfe` 进程,并启动内存 Redis 服务(由测试框架提供)。 +- 不涉及 `ai-gateway-api` 与 `conf-agent`。 + +## 3. 涉及的 BFE 配置文件 + +| 文件 | 说明 | +|------|------| +| `bfe.conf` | 启用 `EnableAiGateway`,加载 `mod_ai_route`、`mod_ai_token_auth`、`mod_ai_rate_limit` | +| `mod_ai_route/mod_ai_route.conf` | 指定 `ai_route.data` 路径 | +| `mod_ai_route/ai_route.data` | 定义 apikey 级路由规则,命中 `cluster_session_affinity` | +| `mod_ai_token_auth/mod_ai_token_auth.conf` | 指定 token 规则路径 | +| `mod_ai_rate_limit/mod_ai_rate_limit.conf` | 指定限流规则路径,并将 `IsRejectOnRedisError` 设为 `false` | +| `server_data_conf/host_rule.data` | 声明 `ai_product` 产品线对应的 Host | +| `server_data_conf/route_rule.data` | 默认路由 | +| `cluster_conf/gslb.data` | GSLB 权重配置 | +| `cluster_conf/cluster_table.data` | 运行时根据 mock 后端地址动态生成 | +| `cluster_conf/cluster_conf.data` | 运行时根据 mock 后端动态生成,包含 `cluster_session_affinity` 的 `AIConf` 与 `KeyPolicy` | + +> 说明:`token_rule.data`、`ai_rate_limit.data` 由测试代码在运行时动态生成,便于控制 token、限流策略与 `AIConf`。 + +## 4. 测试 Cluster 与 Key 配置 + +### 4.1 cluster_session_affinity + +```json +{ + "AIConf": { + "Type": 0, + "Keys": [ + {"Name": "key-a", "Key": "sk-key-a", "Weight": 50}, + {"Name": "key-b", "Key": "sk-key-b", "Weight": 30}, + {"Name": "key-c", "Key": "sk-key-c", "Weight": 20} + ], + "KeyPolicy": { + "Strategy": "weighted_random", + "MaxRetries": 3, + "RetryBackoffInitial": 50, + "RetryBackoffMax": 200, + "SessionAffinity": true, + "SessionAffinityTTL": 300, + "SessionAffinityRedisPrefix": "bfe:ai:key_affinity", + "SessionAffinityPenaltyEnable": true + } + } +} +``` + +> 说明:TC-02 关闭亲和性时将 `SessionAffinity` 设为 `false`;TC-04 单 Key 场景将 `Keys` 简化为仅 `key-a`。 + +### 4.2 Redis 绑定键 + +``` +bfe:ai:key_affinity:cluster_session_affinity: +``` + +其中 `client_key_id` 来自 `token_rule.data` 中配置的 `key_id`。 + +### 4.3 路由表设计 + +#### apikey_ak_session_affinity + +| 规则名 | 条件 | targets | fallbacks | +|--------|------|---------|-----------| +| session-affinity-default | `default_t()` | `cluster_session_affinity` | 无 | + +#### ApikeyRouteTableBindings + +```json +{ + "ak_session_affinity": ["apikey_ak_session_affinity"], + "ak_session_affinity_2": ["apikey_ak_session_affinity"] +} +``` + +> 说明:`ak_session_affinity_2` 用于 TC-05 验证不同 `ClientKeyId` 的独立绑定。 + +## 5. 测试例列表 + +| 编号 | 名称 | 核心验证点 | +|------|------|------------| +| TC-01 | 开启亲和性时同一 ClientKeyId 命中同一 Key | 50 次请求全部使用同一个 Provider Key | +| TC-02 | 关闭亲和性时请求分散到多个 Key | 200 次请求命中至少 2 个不同 Key | +| TC-03 | 亲和 Key 返回 429 后轮换并重新绑定 | 429 后请求切换到其他 Key,Redis 绑定更新 | +| TC-04 | 单 Key cluster 跳过 Redis 绑定 | 仅配一个 Key 时不读写 Redis 绑定 | +| TC-05 | 不同 ClientKeyId 独立绑定 | 两个 ClientKeyId 各自命中一个 Key,Redis 中存在两条绑定 | +| TC-06 | BFE 重启后绑定保持 | 重启 BFE 后同一 ClientKeyId 仍命中原 Provider Key | + +## 5.1 测试例详细说明 + +### TC-01 开启亲和性时同一 ClientKeyId 命中同一 Key + +**目的**:验证开启 `SessionAffinity` 后,同一 `ClientKeyId` 的连续请求被绑定到同一个 Provider Key。 + +**请求构造**: + +| 字段 | 值 | +|------|-----| +| Host | `session-affinity.example.org` | +| Path | `/v1/chat/completions` | +| Header | `Authorization: Bearer ak_session_affinity` | +| Body | `{"model":"gpt-4"}` | + +**执行步骤**: + +1. 启动 BFE,`SessionAffinity = true`; +2. 使用同一 `ak_session_affinity` 连续发送 50 次请求; +3. 检查 mock 后端收到的 `Authorization` 头。 + +**预期结果**: + +- 50 次请求全部返回 200。 +- mock 后端收到的 Provider Key 只有一种(例如 `Bearer sk-key-a`)。 + +### TC-02 关闭亲和性时请求分散到多个 Key + +**目的**:验证 `SessionAffinity = false` 时,请求按加权随机分发,不会绑定到单一 Key。 + +**执行步骤**: + +1. 启动 BFE,`SessionAffinity = false`; +2. 使用同一 `ak_session_affinity` 连续发送 200 次请求; +3. 统计 mock 后端收到的 Provider Key 种类。 + +**预期结果**: + +- 200 次请求全部返回 200。 +- mock 后端收到的 Provider Key 种类数 ≥ 2。 + +### TC-03 亲和 Key 返回 429 后轮换并重新绑定 + +**目的**:验证亲和 Key 被限流后,BFE 能切换到其他 Key 并更新 Redis 绑定。 + +**执行步骤**: + +1. 启动 BFE,`SessionAffinity = true`; +2. 发送第一次请求,建立 Redis 绑定; +3. 修改 mock 后端逻辑:当请求头为已绑定 Key 时返回 429,否则返回 200; +4. 再发送 20 次请求。 + +**预期结果**: + +- 20 次后续请求全部返回 200。 +- 至少有一次请求成功使用了非原绑定 Key。 +- Redis 绑定已更新为新的 Provider Key。 + +### TC-04 单 Key cluster 跳过 Redis 绑定 + +**目的**:验证 cluster 仅配置一个 Key 时,不生成 Redis 绑定,减少不必要的 Redis 访问。 + +**执行步骤**: + +1. 启动 BFE,`AIConf.Keys` 仅保留 `key-a`; +2. 连续发送 10 次请求; +3. 检查 Redis 中是否存在 `bfe:ai:key_affinity:cluster_session_affinity:session_affinity_key_id`。 + +**预期结果**: + +- 10 次请求全部返回 200,且全部使用 `sk-key-a`。 +- Redis 中不存在上述绑定 key。 + +### TC-05 不同 ClientKeyId 独立绑定 + +**目的**:验证不同 `ClientKeyId` 有独立的 Key 绑定,互不影响。 + +**执行步骤**: + +1. 启动 BFE,token 表中配置两个 token:`ak_session_affinity`(`key_id = session_affinity_key_id`)和 `ak_session_affinity_2`(`key_id = session_affinity_key_id_2`); +2. 先用 `ak_session_affinity` 发送 20 次请求; +3. 再用 `ak_session_affinity_2` 发送 20 次请求; +4. 检查 Redis 中两个绑定 key 均存在,并分别统计每组请求使用的 Provider Key。 + +**预期结果**: + +- 两组请求各自只命中一个 Provider Key。 +- Redis 中存在 `bfe:ai:key_affinity:cluster_session_affinity:session_affinity_key_id` 与 `...:session_affinity_key_id_2`。 + +### TC-06 BFE 重启后绑定保持 + +**目的**:验证 Redis 中的绑定在 BFE 进程重启后仍然有效。 + +**执行步骤**: + +1. 启动 BFE,`SessionAffinity = true`; +2. 发送 10 次请求,记录最终绑定的 Provider Key; +3. 停止 BFE,但保持 mock 后端与 Redis 运行; +4. 使用同一配置重新启动 BFE; +5. 再发送 10 次请求。 + +**预期结果**: + +- 重启后发送的所有请求均使用与重启前相同的 Provider Key。 + +## 6. 公共基础设施 + +本场景使用测试框架内置能力: + +1. **`common/redis_server.go`**:启动内存 Redis 服务,供 `mod_ai_rate_limit` 与 Key 亲和性逻辑连接。 +2. **`common/bfe_config_builder.go`**:根据 `TokenRuleData`、`RateLimitPolicyData` 与 `AIConfs` 动态生成配置文件。 +3. **`common/mock_backend.go`**:记录请求头、请求体,并支持按请求动态返回状态码。 + +## 7. 与其他场景的依赖关系 + +- 依赖 SC01 已验证的 `mod_ai_route` 路由表查找与绑定能力; +- 依赖 SC02 已验证的多 API-Key 加权选择与 429 轮换能力; +- 依赖 SC07 已验证的 Redis 服务接入能力; +- 不依赖 SC03 / SC09 的 RMB 计费逻辑。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" index 7e972e234..1f1478625 100644 --- "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/\346\265\213\350\257\225\345\234\272\346\231\257\346\200\273\344\275\223\350\257\264\346\230\216.md" @@ -32,6 +32,7 @@ | SC07 | AI 限流 Redis 键 | 验证 `mod_ai_rate_limit` 使用显式 `redis_key` 进行限流计数,以及规则重命名/模型变更时的计数器连续性 | P0 | 3 | BFE AI 限流 Redis 键 | | SC08 | AI 模型 Fallback 重算 | 验证 cluster 级 fallback 时,fallback cluster 从 `ClientModel` 重新计算目标模型,不继承主 cluster 的映射结果 | P0 | 2 | BFE ProviderModel 前缀裁剪 | | SC09 | RMB 分时段计费 | 验证 `AIConf.ModelTable` 配置 `Tiers` / `TierPrices` 后,按请求发生时刻匹配 tier 价格并扣减 RMB 配额 | P0 | 4 | BFE RMB 分时段计费 | +| SC10 | 同 cluster 多 API-Key 会话保持 | 验证 `AIConf.KeyPolicy.SessionAffinity` 基于 `ClientKeyId` 的会话级 Key 亲和性,包括绑定、轮换、单 Key 短路、跨 BFE 重启保持 | P0 | 6 | BFE 多 API-Key 会话级 Key 亲和性 | ## 4. 公共前置条件 @@ -146,6 +147,17 @@ | TC-03 | `peak` tier + 缓存命中非流式计费 | P0 | | TC-04 | `peak` tier + 缓存命中 SSE 流式计费 | P0 | +### SC10 同 cluster 多 API-Key 会话保持 + +| 测试例编号 | 测试例名称 | 优先级 | +|------------|------------|--------| +| TC-01 | 开启亲和性时同一 ClientKeyId 命中同一 Key | P0 | +| TC-02 | 关闭亲和性时请求分散到多个 Key | P0 | +| TC-03 | 亲和 Key 返回 429 后轮换并重新绑定 | P0 | +| TC-04 | 单 Key cluster 跳过 Redis 绑定 | P0 | +| TC-05 | 不同 ClientKeyId 独立绑定 | P0 | +| TC-06 | BFE 重启后绑定保持 | P0 | + ## 6. 运行方式 ```bash From db9ca3332a9b81677a5f1817f3e1a4ed520996d0 Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Wed, 26 Aug 2026 11:19:17 +0800 Subject: [PATCH 13/18] =?UTF-8?q?feat:=20=E4=BC=9A=E8=AF=9D=E7=BA=A7=20API?= =?UTF-8?q?-Key=20=E4=BA=B2=E5=92=8C=E6=80=A7=20TTL=20=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=A9=BA=E9=97=B2=E8=B6=85=E6=97=B6=E5=B9=B6?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E9=BB=98=E8=AE=A4=E5=80=BC=E4=B8=BA=20600=20?= =?UTF-8?q?=E7=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 SessionAffinityTTL 默认值从 300 秒调整为 600 秒 - 命中 Redis 绑定后调用 Expire 刷新 TTL,实现空闲超时语义 - 同步更新 modifications / sys_design / configuration 中英文文档 - 更新相关单元测试期望值 --- .../cluster_conf/cluster_conf_load.go | 4 ++-- .../cluster_conf/cluster_conf_load_test.go | 4 ++-- bfe_server/reverseproxy.go | 9 ++++++++- bfe_server/reverseproxy_ai_test.go | 4 ++-- .../server_data_conf/cluster_conf.data.md | 7 ++++--- .../server_data_conf/cluster_conf.data.md | 9 +++++---- .../design-changes.md | 15 +++++++++++---- docs/zh_cn/sys_design/multi_api_key.md | 15 +++++++++++---- 8 files changed, 45 insertions(+), 22 deletions(-) diff --git a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go index 42cddfa97..e55db935f 100644 --- a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go +++ b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go @@ -849,10 +849,10 @@ func AIConfCheck(conf *AIConf) error { // AIKeyPolicyCheck checks and fills defaults for AIKeyPolicy. func AIKeyPolicyCheck(policy *AIKeyPolicy) error { if policy.SessionAffinityTTL < 0 { - return fmt.Errorf("SessionAffinityTTL must be >= 0") + return fmt.Errorf("SessionAffinityTTL must be > 0") } if policy.SessionAffinityTTL == 0 { - policy.SessionAffinityTTL = 300 + policy.SessionAffinityTTL = 600 } if policy.SessionAffinityRedisPrefix == "" { policy.SessionAffinityRedisPrefix = "bfe:ai:key_affinity" diff --git a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go index 548c73afc..0206b8ba1 100644 --- a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go +++ b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go @@ -305,8 +305,8 @@ func TestAIConfCheck(t *testing.T) { if err := AIConfCheck(conf); err != nil { t.Errorf("unexpected error: %v", err) } - if conf.KeyPolicy.SessionAffinityTTL != 300 { - t.Errorf("expected SessionAffinityTTL default 300, got %d", conf.KeyPolicy.SessionAffinityTTL) + if conf.KeyPolicy.SessionAffinityTTL != 600 { + t.Errorf("expected SessionAffinityTTL default 600, got %d", conf.KeyPolicy.SessionAffinityTTL) } if conf.KeyPolicy.SessionAffinityRedisPrefix != "bfe:ai:key_affinity" { t.Errorf("expected default redis prefix, got %s", conf.KeyPolicy.SessionAffinityRedisPrefix) diff --git a/bfe_server/reverseproxy.go b/bfe_server/reverseproxy.go index 6cf7710da..bf3013ad7 100644 --- a/bfe_server/reverseproxy.go +++ b/bfe_server/reverseproxy.go @@ -1955,7 +1955,7 @@ func defaultAIKeyPolicy() cluster_conf.AIKeyPolicy { RetryBackoffInitial: 500, RetryBackoffMax: 5000, SessionAffinity: false, - SessionAffinityTTL: 300, + SessionAffinityTTL: 600, SessionAffinityRedisPrefix: "bfe:ai:key_affinity", SessionAffinityPenaltyEnable: true, } @@ -2114,6 +2114,13 @@ func chooseAIKeyWithAffinity( if proxyState != nil { proxyState.ReqAiKeyAffinityHit.Inc(1) } + // Refresh TTL on hit: treat the binding as still in active use. + if err := client.Expire(aiKeyAffinityRedisKey(clusterName, sessionID, policy.SessionAffinityRedisPrefix), policy.SessionAffinityTTL); err != nil { + log.Logger.Warn("aiKeyAffinity: refresh ttl error[%v]", err) + if proxyState != nil { + proxyState.ReqAiKeyAffinityRedisErr.Inc(1) + } + } return idx, keys[idx], boundName, true } // binding stale (key removed/dead/penalized), continue to select a new key diff --git a/bfe_server/reverseproxy_ai_test.go b/bfe_server/reverseproxy_ai_test.go index 1aeea47b6..79dde784b 100644 --- a/bfe_server/reverseproxy_ai_test.go +++ b/bfe_server/reverseproxy_ai_test.go @@ -473,8 +473,8 @@ func TestDefaultAIKeyPolicyIncludesAffinityDefaults(t *testing.T) { if policy.SessionAffinity != false { t.Errorf("expected SessionAffinity false, got %v", policy.SessionAffinity) } - if policy.SessionAffinityTTL != 300 { - t.Errorf("expected SessionAffinityTTL 300, got %d", policy.SessionAffinityTTL) + if policy.SessionAffinityTTL != 600 { + t.Errorf("expected SessionAffinityTTL 600, got %d", policy.SessionAffinityTTL) } if policy.SessionAffinityRedisPrefix != "bfe:ai:key_affinity" { t.Errorf("expected SessionAffinityRedisPrefix bfe:ai:key_affinity, got %s", policy.SessionAffinityRedisPrefix) diff --git a/docs/en_us/configuration/server_data_conf/cluster_conf.data.md b/docs/en_us/configuration/server_data_conf/cluster_conf.data.md index 02954ac92..7b1bb3302 100644 --- a/docs/en_us/configuration/server_data_conf/cluster_conf.data.md +++ b/docs/en_us/configuration/server_data_conf/cluster_conf.data.md @@ -117,14 +117,15 @@ Note: The following configuration items are located in the namespace `Config[v]` | AIConf.KeyPolicy.RetryBackoffInitial | Integer | Initial backoff time, in milliseconds | N | Backoff time for the first retry | >= 0 | | AIConf.KeyPolicy.RetryBackoffMax | Integer | Maximum backoff time, in milliseconds | N | Upper limit of backoff time | >= 0, and must be >= RetryBackoffInitial | | AIConf.KeyPolicy.SessionAffinity | Boolean | Whether to enable session-level API-Key affinity based on Redis + `ClientKeyId` | N | Default `false`; when enabled, requests from the same `ClientKeyId` are bound to the same API-Key | - | -| AIConf.KeyPolicy.SessionAffinityTTL | Integer | TTL of the `ClientKeyId -> KeyName` binding in Redis, in seconds | N | Default `300` | >= 0 | +| AIConf.KeyPolicy.SessionAffinityTTL | Integer | Idle timeout of the `ClientKeyId -> KeyName` binding in Redis, in seconds | N | Default `600`; the TTL is refreshed on each binding hit, so the binding persists as long as the session keeps sending requests | > 0 | | AIConf.KeyPolicy.SessionAffinityRedisPrefix | String | Prefix of the Redis binding key | N | Default `"bfe:ai:key_affinity"` | Non-empty | | AIConf.KeyPolicy.SessionAffinityPenaltyEnable | Boolean | Whether to enable Key penalty: skip Keys that recently returned 429/401/403 | N | Default `true` | - | **Session-level Key Affinity Notes:** - When enabled, BFE uses `AiBasicInfo.ClientKeyId` as the session identifier and maintains a binding `{prefix}:{cluster_name}:{client_key_id} -> ` in Redis. -- Subsequent requests with the same `ClientKeyId` prefer the bound Key, which improves the upstream provider's prompt cache hit rate (especially when multiple Keys belong to different provider accounts). +- Subsequent requests with the same `ClientKeyId` prefer the bound Key; on each hit, BFE refreshes the binding TTL via `Expire`, so the binding persists as long as the session keeps sending requests. +- `SessionAffinityTTL` is the **idle timeout**: the binding is released automatically only when no request arrives within the TTL window. - If the bound Key is penalized, deleted, or has weight `0`, a new Key is selected and the binding is updated. - If Redis is unavailable, the affinity logic gracefully degrades to weighted random selection without affecting request success rate. - If `Keys` contains only one valid Key, it is returned directly without accessing Redis. @@ -330,7 +331,7 @@ Note: The following configuration items are located in the namespace `Config[v]` "RetryBackoffInitial": 500, "RetryBackoffMax": 5000, "SessionAffinity": true, - "SessionAffinityTTL": 300, + "SessionAffinityTTL": 600, "SessionAffinityRedisPrefix": "bfe:ai:key_affinity", "SessionAffinityPenaltyEnable": true }, diff --git a/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md b/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md index ca9232a66..fa5628531 100644 --- a/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md +++ b/docs/zh_cn/configuration/server_data_conf/cluster_conf.data.md @@ -260,7 +260,7 @@ "RetryBackoffInitial": 500, "RetryBackoffMax": 5000, "SessionAffinity": true, - "SessionAffinityTTL": 300, + "SessionAffinityTTL": 600, "SessionAffinityRedisPrefix": "bfe:ai:key_affinity", "SessionAffinityPenaltyEnable": true } @@ -273,14 +273,15 @@ | RetryBackoffInitial | integer | N | 首次重试的退避时间,单位毫秒 | >= 0 | | RetryBackoffMax | integer | N | 最大退避时间,单位毫秒 | >= 0,且须 >= `RetryBackoffInitial` | | SessionAffinity | boolean | N | 是否开启基于 Redis + `ClientKeyId` 的会话级 Key 亲和性;默认 `false` | - | -| SessionAffinityTTL | integer | N | Redis 中 `ClientKeyId -> KeyName` 绑定的过期时间,单位秒;默认 `300` | >= 0 | +| SessionAffinityTTL | integer | N | `ClientKeyId -> KeyName` 绑定的空闲超时时间,单位秒;命中绑定后会刷新 TTL,持续请求则绑定保持;默认 `600` | > 0 | | SessionAffinityRedisPrefix | string | N | Redis 绑定键的前缀;默认 `"bfe:ai:key_affinity"` | 非空 | | SessionAffinityPenaltyEnable | boolean | N | 是否开启 Key 惩罚:选择时跳过近期返回 429/401/403 的 Key;默认 `true` | - | **会话级 Key 亲和性说明:** - 开启后,BFE 使用 `AiBasicInfo.ClientKeyId` 作为会话标识,在 Redis 中维护 `{prefix}:{cluster_name}:{client_key_id} -> ` 的绑定。 -- 同一 `ClientKeyId` 的后续请求优先命中已绑定的 Key,从而提升 Provider 侧 prompt cache 命中率(适用于多 Key 来自不同 Provider 账户的场景)。 +- 同一 `ClientKeyId` 的后续请求优先命中已绑定的 Key;命中后会通过 `Expire` 刷新绑定 TTL,因此只要会话持续有请求,绑定就会一直保持。 +- `SessionAffinityTTL` 是**空闲超时时间**:只有当 `ClientKeyId` 在 TTL 时间内没有请求时,绑定才会自动释放。 - 当绑定 Key 被惩罚、被删除或权重为 `0` 时,自动重新选择并写入新绑定。 - Redis 不可用时自动降级为无亲和的加权随机选择,不影响请求成功率。 - 若 `Keys` 中只有一个有效 Key,直接短路返回,不会访问 Redis。 @@ -544,7 +545,7 @@ "RetryBackoffInitial": 500, "RetryBackoffMax": 5000, "SessionAffinity": true, - "SessionAffinityTTL": 300, + "SessionAffinityTTL": 600, "SessionAffinityRedisPrefix": "bfe:ai:key_affinity", "SessionAffinityPenaltyEnable": true }, diff --git a/docs/zh_cn/modifications/2026-08-26-ai-key-session-affinity/design-changes.md b/docs/zh_cn/modifications/2026-08-26-ai-key-session-affinity/design-changes.md index 44e6a6fe2..175246483 100644 --- a/docs/zh_cn/modifications/2026-08-26-ai-key-session-affinity/design-changes.md +++ b/docs/zh_cn/modifications/2026-08-26-ai-key-session-affinity/design-changes.md @@ -60,7 +60,7 @@ type AIKeyPolicy struct { // 新增 SessionAffinity bool // 默认 false - SessionAffinityTTL int // 默认 300 秒 + SessionAffinityTTL int // 绑定空闲超时时间,单位秒,默认 600 SessionAffinityRedisPrefix string // 默认 "bfe:ai:key_affinity" SessionAffinityPenaltyEnable bool // 默认 true } @@ -71,7 +71,7 @@ type AIKeyPolicy struct { | 字段 | 类型 | 默认值 | 说明 | |---|---|---|---| | `session_affinity` | bool | false | 是否开启基于 Redis + ClientKeyId 的会话级 Key 亲和 | -| `session_affinity_ttl` | int | 300 | Redis 绑定过期时间(秒) | +| `session_affinity_ttl` | int | 600 | 绑定空闲超时时间(秒);命中绑定后会刷新 TTL,持续请求则绑定保持 | | `session_affinity_redis_prefix` | string | "bfe:ai:key_affinity" | Redis key 前缀 | | `session_affinity_penalty_enable` | bool | true | 是否跳过近期 429/401/403 的 Key | @@ -84,17 +84,19 @@ Redis 连接优先复用 `mod_ai_rate_limit` 已配置的客户端;如需解 ``` key: {prefix}:{cluster_name}:{client_key_id} value: -TTL: session_affinity_ttl 秒 +TTL: session_affinity_ttl 秒(每次命中后刷新) ``` 示例: ``` -bfe:ai:key_affinity:deepseek-cluster:ckt-abc-123 -> "key-primary" (TTL=300) +bfe:ai:key_affinity:deepseek-cluster:ckt-abc-123 -> "key-primary" (TTL=600) ``` 使用 `key_name` 而非数组下标,避免配置重载后下标变化导致绑定失效。 +`session_affinity_ttl` 是**空闲超时时间**,不是固定生命周期。只要同一个 `client_key_id` 在超时时间内持续请求,BFE 就会在命中绑定后调用 `Expire` 刷新 TTL;只有会话空闲超过该时间,绑定才会自动释放。 + #### 6.2.2 Key 惩罚(可选) ``` @@ -162,6 +164,11 @@ func chooseAIKeyWithAffinity( if idx >= 0 && isKeyAlive(idx, keys, state) && !isKeyPenalized(clusterName, boundName, redisClient) { incAffinityHit(clusterName) + // 刷新 TTL:将该绑定视为仍在活跃会话中使用 + if err := redisClient.Expire(redisKey(clusterName, sessionID, policy.SessionAffinityRedisPrefix), policy.SessionAffinityTTL); err != nil { + log.Logger.Warn("aiKeyAffinity: refresh ttl error[%v]", err) + incAffinityRedisErr(clusterName) + } return idx, keys[idx], true } } diff --git a/docs/zh_cn/sys_design/multi_api_key.md b/docs/zh_cn/sys_design/multi_api_key.md index 17fbadb62..28637769b 100644 --- a/docs/zh_cn/sys_design/multi_api_key.md +++ b/docs/zh_cn/sys_design/multi_api_key.md @@ -42,7 +42,7 @@ type AIKeyPolicy struct { // 会话级 Key 亲和性 SessionAffinity bool // 默认 false - SessionAffinityTTL int // Redis 绑定 TTL,单位秒,默认 300 + SessionAffinityTTL int // 绑定空闲超时时间,单位秒,默认 600 SessionAffinityRedisPrefix string // Redis key 前缀,默认 "bfe:ai:key_affinity" SessionAffinityPenaltyEnable bool // 是否开启 Key 惩罚,默认 true } @@ -416,15 +416,17 @@ func calcBackoff(initial, max, attempt int) time.Duration { ``` key: {prefix}:{cluster_name}:{client_key_id} value: -TTL: session_affinity_ttl 秒 +TTL: session_affinity_ttl 秒(每次命中后刷新) ``` 示例: ``` -bfe:ai:key_affinity:deepseek-cluster:ckt-abc-123 -> "key-primary" (TTL=300) +bfe:ai:key_affinity:deepseek-cluster:ckt-abc-123 -> "key-primary" (TTL=600) ``` +`session_affinity_ttl` 是**空闲超时时间**,不是固定生命周期。只要同一个 `client_key_id` 在超时时间内持续请求,BFE 就会在命中绑定后调用 `Expire` 刷新 TTL;只有当会话空闲超过该时间,绑定才会自动释放。 + **Key 惩罚(可选):** ``` @@ -483,6 +485,11 @@ func chooseAIKeyWithAffinity( if idx >= 0 && isKeyAlive(idx, keys, state) && !isKeyPenalized(clusterName, boundName, redisClient) { incAffinityHit(clusterName) + // 刷新 TTL:将该绑定视为仍在活跃会话中使用 + if err := redisClient.Expire(redisKey(clusterName, sessionID, policy.SessionAffinityRedisPrefix), policy.SessionAffinityTTL); err != nil { + log.Logger.Warn("aiKeyAffinity: refresh ttl error[%v]", err) + incAffinityRedisErr(clusterName) + } return idx, keys[idx], true } } @@ -623,7 +630,7 @@ aiClusterInvoke: all ai keys exhausted for cluster[%s] "RetryBackoffInitial": 500, "RetryBackoffMax": 5000, "SessionAffinity": true, - "SessionAffinityTTL": 300, + "SessionAffinityTTL": 600, "SessionAffinityRedisPrefix": "bfe:ai:key_affinity", "SessionAffinityPenaltyEnable": true }, From 77efbec450aaa4cd81b1fd849911914eb35238e2 Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Wed, 26 Aug 2026 16:29:53 +0800 Subject: [PATCH 14/18] feat(redis_client): add GetInt64Batch for batch quota remaining reads Add GetInt64Batch to redis_client.Client interface and RedisClient implementation to support MGET-based batch quota balance reads in ai-gateway-api. Fix mockRedisClient implementations in mod_ai_token_auth and bfe_server tests to implement the new interface method. --- .../mod_ai_token_auth_test.go | 12 +++++ bfe_server/reverseproxy_ai_test.go | 4 ++ bfe_util/redis_client/client.go | 1 + bfe_util/redis_client/redis_bns.go | 48 +++++++++++++++++++ 4 files changed, 65 insertions(+) diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go index 319899db0..dc2fcd3c6 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go @@ -1428,6 +1428,18 @@ func (m *mockRedisClient) GetInt64(key string) (int64, error) { return 0, fmt.Errorf("key not found") } +func (m *mockRedisClient) GetInt64Batch(keys []string) ([]int64, error) { + result := make([]int64, len(keys)) + for i, key := range keys { + if v, ok := m.data[key]; ok { + result[i] = v + } else { + result[i] = 0 + } + } + return result, nil +} + func (m *mockRedisClient) IncrBy(key string, delta int64) (int64, error) { m.data[key] += delta return m.data[key], nil diff --git a/bfe_server/reverseproxy_ai_test.go b/bfe_server/reverseproxy_ai_test.go index 79dde784b..e4bd54ffd 100644 --- a/bfe_server/reverseproxy_ai_test.go +++ b/bfe_server/reverseproxy_ai_test.go @@ -455,6 +455,10 @@ func (m *mockRedisClient) GetInt64(key string) (int64, error) { panic("not implemented") } +func (m *mockRedisClient) GetInt64Batch(keys []string) ([]int64, error) { + panic("not implemented") +} + func (m *mockRedisClient) IncrBy(key string, delta int64) (int64, error) { panic("not implemented") } diff --git a/bfe_util/redis_client/client.go b/bfe_util/redis_client/client.go index 86b2d22a8..4d19cfde0 100644 --- a/bfe_util/redis_client/client.go +++ b/bfe_util/redis_client/client.go @@ -35,6 +35,7 @@ type Client interface { Decr(key string) (int64, error) PIncr([]string) ([]int64, error) GetInt64(key string) (int64, error) + GetInt64Batch(keys []string) ([]int64, error) IncrBy(key string, delta int64) (int64, error) Delete(key string) error NewScript(src string) RedisScript diff --git a/bfe_util/redis_client/redis_bns.go b/bfe_util/redis_client/redis_bns.go index 8d0adbdf8..fd451c13f 100644 --- a/bfe_util/redis_client/redis_bns.go +++ b/bfe_util/redis_client/redis_bns.go @@ -492,6 +492,54 @@ func (c *RedisClient) GetInt64(key string) (int64, error) { return value, nil } +// get multiple int64 values from redis by keys +// keys are grouped by redis cluster and executed with MGET per cluster +func (c *RedisClient) GetInt64Batch(keys []string) ([]int64, error) { + if len(keys) == 0 { + return []int64{}, nil + } + + c.incrModuleState2(RedisGet) + + // group keys by cluster id + groups := make(map[int][]string) + for _, key := range keys { + clusterID := c.getClusterIdByKey(key) + groups[clusterID] = append(groups[clusterID], key) + } + + results := make([]int64, len(keys)) + keyIndex := make(map[string]int, len(keys)) + for i, key := range keys { + keyIndex[key] = i + } + + procStart := time.Now() + for clusterID, groupKeys := range groups { + conn := c.getConnByClusterId(clusterID) + + args := make([]interface{}, len(groupKeys)) + for i, key := range groupKeys { + args[i] = key + } + + values, err := redis.Int64s(conn.Do("MGET", args...)) + conn.Close() + if err != nil { + c.incrModuleState2(RedisGetFail) + return nil, err + } + + for i, key := range groupKeys { + results[keyIndex[key]] = values[i] + } + } + + c.setDelayState(c.delay, procStart) + c.incrModuleState2(RedisGetHit) + return results, nil +} + // set expire to redis func (c *RedisClient) Expire(key string, expire int) error { c.incrModuleState2(RedisExpire) From 232a59e5be4a3da49975289d6bcd4fc8849095c4 Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Thu, 27 Aug 2026 16:58:25 +0800 Subject: [PATCH 15/18] feat: recognize DeepSeek cache fields in usage parsing - Add fallback for usage.prompt_cache_hit_tokens and usage.prompt_tokens_details.cached_tokens in: - mod_ai_token_auth.UpdateCtxByUsage - mod_body_process.SSEEvent.GetQuotaUsage - mod_body_process.RawEvent.GetQuotaUsage - Add unit tests for DeepSeek cache field parsing - Add SC09 integration tests TC05/TC06 for DeepSeek cache fields - Update rmb_quota.md sys_design doc to document DeepSeek cache fallback - Add design-changes.md under modifications/2026-08-27-deepseek-busy-hour-billing-fix Relates to ai-gateway-api#101 --- .../mod_ai_token_auth/mod_ai_token_auth.go | 8 + .../mod_ai_token_auth_test.go | 29 +++ bfe_modules/mod_body_process/body_process.go | 8 + .../content_quota_usage_test.go | 40 ++++ bfe_modules/mod_body_process/llm_util.go | 8 + bfe_modules/mod_body_process/llm_util_test.go | 23 ++ .../design-changes.md | 220 ++++++++++++++++++ docs/zh_cn/sys_design/rmb_quota.md | 22 +- .../sc09_rmb_tiered_pricing_test.go | 84 +++++++ ...01\345\274\217\350\256\241\350\264\271.md" | 88 +++++++ ...01\345\274\217\350\256\241\350\264\271.md" | 87 +++++++ 11 files changed, 614 insertions(+), 3 deletions(-) create mode 100644 docs/zh_cn/modifications/2026-08-27-deepseek-busy-hour-billing-fix/design-changes.md create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-05-peak-tier-DeepSeek\347\274\223\345\255\230\345\221\275\344\270\255\345\255\227\346\256\265\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" create mode 100644 "tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-06-peak-tier-DeepSeek\347\274\223\345\255\230\345\221\275\344\270\255\345\255\227\346\256\265SSE\346\265\201\345\274\217\350\256\241\350\264\271.md" diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go index 67918e1a1..f1e59d710 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go @@ -126,6 +126,14 @@ func UpdateCtxByUsage(ctx *TokenAuthContext, data []byte) { imageCount = gjson.GetBytes(data, "data.#").Int() } + // DeepSeek fallback: prompt_cache_hit_tokens / prompt_tokens_details.cached_tokens + if cacheRead == 0 { + cacheRead = gjson.GetBytes(data, "usage.prompt_cache_hit_tokens").Int() + } + if cacheRead == 0 { + cacheRead = gjson.GetBytes(data, "usage.prompt_tokens_details.cached_tokens").Int() + } + // Claude fallback: input_tokens / output_tokens / cache_read_input_tokens / cache_creation_input_tokens if prompt == 0 && completion == 0 { prompt = gjson.GetBytes(data, "usage.input_tokens").Int() diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go index dc2fcd3c6..f66af8416 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go @@ -358,6 +358,35 @@ func TestUpdateCtxByUsage_Cache(t *testing.T) { } } +func TestUpdateCtxByUsage_DeepSeekCache(t *testing.T) { + req := newTestRequest("", "AI_product") + ai := req.InitAiBasicInfo() + ctx := &TokenAuthContext{aiBasicInfo: ai} + + // DeepSeek: prompt_cache_hit_tokens + UpdateCtxByUsage(ctx, []byte(`{"usage":{"total_tokens":12,"prompt_tokens":8,"completion_tokens":4,"prompt_cache_hit_tokens":5}}`)) + usage := ai.GetTokenUsage() + if usage.CacheReadTokens != 5 { + t.Errorf("expected CacheReadTokens 5 for prompt_cache_hit_tokens, got %d", usage.CacheReadTokens) + } + + // DeepSeek: prompt_tokens_details.cached_tokens + ctx2 := &TokenAuthContext{aiBasicInfo: ai} + UpdateCtxByUsage(ctx2, []byte(`{"usage":{"total_tokens":12,"prompt_tokens":8,"completion_tokens":4,"prompt_tokens_details":{"cached_tokens":6}}}`)) + usage = ai.GetTokenUsage() + if usage.CacheReadTokens != 6 { + t.Errorf("expected CacheReadTokens 6 for prompt_tokens_details.cached_tokens, got %d", usage.CacheReadTokens) + } + + // Existing cache_read_tokens takes precedence when non-zero + ctx3 := &TokenAuthContext{aiBasicInfo: ai} + UpdateCtxByUsage(ctx3, []byte(`{"usage":{"total_tokens":12,"prompt_tokens":8,"completion_tokens":4,"cache_read_tokens":3,"prompt_cache_hit_tokens":5}}`)) + usage = ai.GetTokenUsage() + if usage.CacheReadTokens != 3 { + t.Errorf("expected CacheReadTokens 3 (existing field precedence), got %d", usage.CacheReadTokens) + } +} + func TestTokenAuthContext(t *testing.T) { req := newTestRequest("", "AI_product") ai := req.InitAiBasicInfo() diff --git a/bfe_modules/mod_body_process/body_process.go b/bfe_modules/mod_body_process/body_process.go index e2eb91534..c1706702d 100644 --- a/bfe_modules/mod_body_process/body_process.go +++ b/bfe_modules/mod_body_process/body_process.go @@ -435,6 +435,14 @@ func (e *RawEvent) GetQuotaUsage() QuotaUsage { imageCount = gjson.GetBytes(*e, "data.#").Int() } + // DeepSeek fallback: prompt_cache_hit_tokens / prompt_tokens_details.cached_tokens + if cacheRead == 0 { + cacheRead = gjson.GetBytes(*e, "usage.prompt_cache_hit_tokens").Int() + } + if cacheRead == 0 { + cacheRead = gjson.GetBytes(*e, "usage.prompt_tokens_details.cached_tokens").Int() + } + // Claude fallback: input_tokens / output_tokens / cache_read_input_tokens / cache_creation_input_tokens if prompt == 0 && completion == 0 { prompt = gjson.GetBytes(*e, "usage.input_tokens").Int() diff --git a/bfe_modules/mod_body_process/content_quota_usage_test.go b/bfe_modules/mod_body_process/content_quota_usage_test.go index c5164c19c..b15e2ab53 100644 --- a/bfe_modules/mod_body_process/content_quota_usage_test.go +++ b/bfe_modules/mod_body_process/content_quota_usage_test.go @@ -105,6 +105,46 @@ func TestQuotaUsageProcessorProcessWithCache(t *testing.T) { } } +func TestQuotaUsageProcessorProcessWithDeepSeekCache(t *testing.T) { + req := newTestRequest("AI_product") + ai := req.InitAiBasicInfo() + res := &bfe_http.Response{StatusCode: bfe_http.StatusOK} + p := NewQuotaUsageProcessor(req, res) + + events := []Event{newRawEvent(`{"usage":{"total_tokens":12,"prompt_tokens":8,"completion_tokens":4,"prompt_cache_hit_tokens":5}}`)} + out, err := p.Process(events) + if err != nil { + t.Fatalf("Process failed: %s", err) + } + if len(out) != 1 { + t.Errorf("expected 1 event, got %d", len(out)) + } + usage := ai.GetTokenUsage() + if usage.CacheReadTokens != 5 { + t.Errorf("expected CacheReadTokens 5, got %d", usage.CacheReadTokens) + } +} + +func TestQuotaUsageProcessorProcessWithDeepSeekCacheDetails(t *testing.T) { + req := newTestRequest("AI_product") + ai := req.InitAiBasicInfo() + res := &bfe_http.Response{StatusCode: bfe_http.StatusOK} + p := NewQuotaUsageProcessor(req, res) + + events := []Event{newRawEvent(`{"usage":{"total_tokens":12,"prompt_tokens":8,"completion_tokens":4,"prompt_tokens_details":{"cached_tokens":6}}}`)} + out, err := p.Process(events) + if err != nil { + t.Fatalf("Process failed: %s", err) + } + if len(out) != 1 { + t.Errorf("expected 1 event, got %d", len(out)) + } + usage := ai.GetTokenUsage() + if usage.CacheReadTokens != 6 { + t.Errorf("expected CacheReadTokens 6, got %d", usage.CacheReadTokens) + } +} + func TestQuotaUsageProcessorProcessWithAudio(t *testing.T) { req := newTestRequest("AI_product") ai := req.InitAiBasicInfo() diff --git a/bfe_modules/mod_body_process/llm_util.go b/bfe_modules/mod_body_process/llm_util.go index aa9cba343..328963924 100644 --- a/bfe_modules/mod_body_process/llm_util.go +++ b/bfe_modules/mod_body_process/llm_util.go @@ -134,6 +134,14 @@ func (e *SSEEvent) GetQuotaUsage() QuotaUsage { imageCount = gjson.GetBytes(data, "data.#").Int() } + // DeepSeek fallback: prompt_cache_hit_tokens / prompt_tokens_details.cached_tokens + if cacheRead == 0 { + cacheRead = gjson.GetBytes(data, "usage.prompt_cache_hit_tokens").Int() + } + if cacheRead == 0 { + cacheRead = gjson.GetBytes(data, "usage.prompt_tokens_details.cached_tokens").Int() + } + // Claude fallback: input_tokens / output_tokens / cache_read_input_tokens / cache_creation_input_tokens if prompt == 0 && completion == 0 { prompt = gjson.GetBytes(data, "usage.input_tokens").Int() diff --git a/bfe_modules/mod_body_process/llm_util_test.go b/bfe_modules/mod_body_process/llm_util_test.go index 9eb0ae432..9aafbbadc 100644 --- a/bfe_modules/mod_body_process/llm_util_test.go +++ b/bfe_modules/mod_body_process/llm_util_test.go @@ -121,6 +121,29 @@ func TestSSEEventGetQuotaUsageWithAudio(t *testing.T) { } } +func TestSSEEventGetQuotaUsage_DeepSeekCache(t *testing.T) { + // DeepSeek: prompt_cache_hit_tokens + ev := &SSEEvent{DataLines: [][]byte{[]byte(`{"usage":{"total_tokens":12,"prompt_tokens":8,"completion_tokens":4,"prompt_cache_hit_tokens":5}}`)}} + usage := ev.GetQuotaUsage() + if usage.CacheReadTokens != 5 { + t.Errorf("expected CacheReadTokens 5 for prompt_cache_hit_tokens, got %d", usage.CacheReadTokens) + } + + // DeepSeek: prompt_tokens_details.cached_tokens + ev2 := &SSEEvent{DataLines: [][]byte{[]byte(`{"usage":{"total_tokens":12,"prompt_tokens":8,"completion_tokens":4,"prompt_tokens_details":{"cached_tokens":6}}}`)}} + usage2 := ev2.GetQuotaUsage() + if usage2.CacheReadTokens != 6 { + t.Errorf("expected CacheReadTokens 6 for prompt_tokens_details.cached_tokens, got %d", usage2.CacheReadTokens) + } + + // Existing cache_read_tokens takes precedence when non-zero + ev3 := &SSEEvent{DataLines: [][]byte{[]byte(`{"usage":{"total_tokens":12,"prompt_tokens":8,"completion_tokens":4,"cache_read_tokens":3,"prompt_cache_hit_tokens":5}}`)}} + usage3 := ev3.GetQuotaUsage() + if usage3.CacheReadTokens != 3 { + t.Errorf("expected CacheReadTokens 3 (existing field precedence), got %d", usage3.CacheReadTokens) + } +} + func TestSSEEventSetJsonField(t *testing.T) { ev := &SSEEvent{DataLines: [][]byte{[]byte(`{"text":"hello"}`)}} if err := ev.SetJsonField("text", "world"); err != nil { diff --git a/docs/zh_cn/modifications/2026-08-27-deepseek-busy-hour-billing-fix/design-changes.md b/docs/zh_cn/modifications/2026-08-27-deepseek-busy-hour-billing-fix/design-changes.md new file mode 100644 index 000000000..c057c58c7 --- /dev/null +++ b/docs/zh_cn/modifications/2026-08-27-deepseek-busy-hour-billing-fix/design-changes.md @@ -0,0 +1,220 @@ +# 修复 DeepSeek 忙时 cache 扣款不一致(issue #101) + +## 1. 背景 + +线上收到 [ai-gateway-api#101](https://github.com/rainway-ai-gateway/ai-gateway-api/issues/101) 反馈:同一 DeepSeek 流量在 DeepSeek 控制台扣款约 ¥0.09,而 AI Gateway(BFE)后台扣款 ¥0.2245,差额约 ¥0.13。 + +经过对账,三次请求的关键数据如下: + +| 指标 | R1 | R2 | R3 | 合计 | +|------|------|------|------|------| +| `prompt_tokens` | 33,023 | 35,867 | 36,584 | 105,474 | +| — 其中 cache hit | 13,696 | 34,048 | 36,352 | 84,096 (79.7%) | +| — 其中 cache miss | 19,327 | 1,819 | 232 | 21,378 | +| `completion_tokens` | 1,065 | 497 | 1,149 | 2,711 | +| BFE 扣款(¥) | 0.071371 | 0.074219 | 0.078913 | **0.224503** | + +问题核心:**BFE 把 84,096 个 cache-hit token 按输入全价计费**。本方案聚焦在 BFE 侧让 DeepSeek 的 cache 命中字段能被正确识别并写入 `TokenUsage.CacheReadTokens`,后续 cache 拆分计费、忙时 tier 生效等由既有逻辑或配置链路负责。 + +--- + +## 2. BFE 侧根因分析 + +### 2.1 Usage 解析层不认识 DeepSeek 的 cache 字段 + +当前 BFE 在三个地方解析 usage: + +- 非流式:`bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go:114-168` 的 `UpdateCtxByUsage` +- 流式:`bfe_modules/mod_body_process/llm_util.go:123-172` 的 `SSEEvent.GetQuotaUsage` +- 非流式(`RawEvent`):`bfe_modules/mod_body_process/body_process.go:422-471` 的 `RawEvent.GetQuotaUsage` + +它们目前只识别: + +- `usage.cache_read_tokens` / `usage.cache_write_tokens` +- Claude 兼容字段:`usage.cache_read_input_tokens` / `usage.cache_creation_input_tokens` + +DeepSeek 实际返回的是: + +- `usage.prompt_cache_hit_tokens` +- `usage.prompt_tokens_details.cached_tokens` + +由于这两个字段没有被解析,`TokenUsage.CacheReadTokens` 始终为 0,后续计费时 cache hit token 被全额计入普通 input。 + +### 2.2 其他根因(不在本次 BFE 代码修复范围内) + +- **`cache_read_input_token_cost = 0` 与“未配置”无法区分**:需要 `model_prices` 配置侧显式、正确地维护 DeepSeek cache 单价。 +- **忙时 tier 未生效**:需要控制面 / `conf-agent` 把 provider 的 `tiers` 正确拼入 cluster 的 `ModelTable.Tiers`。 + +本方案只解决 **2.1**,让 BFE 拿到正确的 `CacheReadTokens`,为 2.2 的正确计费提供前提数据。 + +--- + +## 3. 变更目标 + +1. **识别 DeepSeek cache 字段**:让非流式、流式、RawEvent 三处 usage 解析都支持 `usage.prompt_cache_hit_tokens` 和 `usage.prompt_tokens_details.cached_tokens`。 + +--- + +## 4. 变更总览 + +| 模块 | 主要改动 | +|------|----------| +| `mod_ai_token_auth` | `UpdateCtxByUsage` 解析 DeepSeek cache 字段 | +| `mod_body_process` | `SSEEvent.GetQuotaUsage` / `RawEvent.GetQuotaUsage` 解析 DeepSeek cache 字段 | +| 测试 | 补充 DeepSeek cache 字段解析的单元测试与集成测试 | + +--- + +## 5. 详细设计 + +### 5.1 解析 DeepSeek cache 字段 + +在三处 usage 解析中,保持现有字段优先,当 `cacheRead == 0` 时再尝试 DeepSeek 字段: + +```go +// 1. 现有字段 +cacheRead = gjson.GetBytes(data, "usage.cache_read_tokens").Int() + +// 2. DeepSeek 字段兜底 +if cacheRead == 0 { + cacheRead = gjson.GetBytes(data, "usage.prompt_cache_hit_tokens").Int() +} +if cacheRead == 0 { + cacheRead = gjson.GetBytes(data, "usage.prompt_tokens_details.cached_tokens").Int() +} + +// 3. Claude 字段兜底(保留现有逻辑) +if cacheRead == 0 { + cacheRead = gjson.GetBytes(data, "usage.cache_read_input_tokens").Int() +} +``` + +需要修改的文件: + +- `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go`:`UpdateCtxByUsage` +- `bfe/bfe_modules/mod_body_process/llm_util.go`:`SSEEvent.GetQuotaUsage` +- `bfe/bfe_modules/mod_body_process/body_process.go`:`RawEvent.GetQuotaUsage` + +### 5.2 字段优先级与兼容性 + +- 若后端同时返回 `cache_read_tokens` 和 `prompt_cache_hit_tokens`,以非零的现有字段为准,避免重复计算。 +- 新增字段仅影响 `CacheReadTokens` 的取值,不影响 `PromptTokens`、`CompletionTokens`、`UsedQuota` 等既有字段。 +- 后端未返回任何 cache 字段时,行为与现在完全一致。 + +--- + +## 6. 计费公式(依赖既有 cache 拆分逻辑) + +当 `CacheReadTokens` 被正确填充后,BFE 现有 `calcChatCost` 会按以下公式计费: + +``` +cacheRead = min(CacheReadTokens, PromptTokens) +normalInput = PromptTokens - cacheRead + +cost = normalInput × input_cost_per_token + + cacheRead × cache_read_input_token_cost + + CompletionTokens × output_cost_per_token +``` + +> 注:本方案不修改计费公式本身,只保证 `CacheReadTokens` 来自 DeepSeek 的真实返回。 + +--- + +## 7. 边界情况 + +| 场景 | 处理 | +|------|------| +| 后端返回 `usage.prompt_cache_hit_tokens` | 解析为 `CacheReadTokens` | +| 后端返回 `usage.prompt_tokens_details.cached_tokens` | 解析为 `CacheReadTokens` | +| 同时返回多个 cache 字段 | 以第一个非零值为准 | +| 后端未返回任何 cache 字段 | `CacheReadTokens = 0`,行为不变 | +| `CacheReadTokens > PromptTokens` | 由 `calcChatCost` 截断为 `PromptTokens` | +| 流式响应中 cache usage 在最后一个 chunk | 最后一个非 guess 事件覆盖,与现有逻辑一致 | + +--- + +## 8. 测试计划 + +### 8.1 单元测试 + +#### `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go` + +1. `TestUpdateCtxByUsage_DeepSeekCache`:验证 `usage.prompt_cache_hit_tokens` 被正确解析为 `CacheReadTokens`。 +2. `TestUpdateCtxByUsage_DeepSeekCacheDetails`:验证 `usage.prompt_tokens_details.cached_tokens` 被正确解析为 `CacheReadTokens`。 +3. `TestUpdateCtxByUsage_DeepSeekCachePriority`:多个字段同时存在时,优先使用非零的现有字段。 + +#### `bfe/bfe_modules/mod_body_process/content_quota_usage_test.go` + +4. `TestQuotaUsageProcessorProcessWithDeepSeekCache`:非流式 `RawEvent` 返回 `usage.prompt_cache_hit_tokens`,`TokenUsage.CacheReadTokens` 正确。 +5. `TestQuotaUsageProcessorProcessWithDeepSeekCacheDetails`:非流式 `RawEvent` 返回 `usage.prompt_tokens_details.cached_tokens`,`TokenUsage.CacheReadTokens` 正确。 + +#### `bfe/bfe_modules/mod_body_process/llm_util_test.go`(如不存在则新增) + +6. `TestSSEEventGetQuotaUsage_DeepSeekCache`:SSE 最终 chunk 含 `usage.prompt_cache_hit_tokens`,`CacheReadTokens` 正确。 +7. `TestSSEEventGetQuotaUsage_DeepSeekCacheDetails`:SSE 最终 chunk 含 `usage.prompt_tokens_details.cached_tokens`,`CacheReadTokens` 正确。 + +### 8.2 集成测试 + +在 `bfe/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/` 中新增: + +1. `TestTC05_RMBQuotaDeduction_DeepSeekCacheField_NonStreaming`: + - mock 后端返回 `usage.prompt_tokens_details.cached_tokens`; + - `ModelTable` 配置 `cache_read_input_token_cost`; + - 验证 Redis 按 cache 拆分公式扣减。 + +2. `TestTC06_RMBQuotaDeduction_DeepSeekCacheField_Streaming`: + - 与 TC05 相同,但响应为 SSE 流; + - 验证流式场景下 DeepSeek cache 字段同样生效。 + +在 `bfe/tests/integration/测试设计文档/scenario-SC09-RMB分时段计费/` 中补充对应 `TC-05`、`TC-06` 设计文档。 + +--- + +## 9. 影响范围 + +| 文件 | 影响 | +|------|------| +| `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` | 非流式 usage 解析增加 DeepSeek cache 字段 | +| `bfe/bfe_modules/mod_body_process/llm_util.go` | 流式 usage 解析增加 DeepSeek cache 字段 | +| `bfe/bfe_modules/mod_body_process/body_process.go` | `RawEvent` usage 解析增加 DeepSeek cache 字段 | +| 相关 `*_test.go` | 新增/补充测试 | +| `bfe/docs/zh_cn/modifications/2026-08-27-deepseek-busy-hour-billing-fix/design-changes.md` | 本设计文档 | + +--- + +## 10. 兼容性与风险 + +### 10.1 兼容性 + +- 未返回 DeepSeek cache 字段的后端行为完全不变。 +- 已有 `cache_read_tokens` / `cache_write_tokens` 字段的模型行为不变。 +- Token 配额(`total_token`)扣减逻辑不变。 +- Redis 定点数存储、key 结构不变。 + +### 10.2 风险与缓解 + +| 风险 | 缓解措施 | +|------|----------| +| DeepSeek 字段解析优先级与现有字段冲突 | 仅当现有字段为 0 时才 fallback,避免重复计费 | +| `prompt_tokens_details.cached_tokens` 为嵌套对象 | 使用 `gjson` 路径 `usage.prompt_tokens_details.cached_tokens` 直接取值 | +| 不同 DeepSeek 版本返回字段不一致 | 同时支持 `prompt_cache_hit_tokens` 和 `prompt_tokens_details.cached_tokens` | + +--- + +## 11. 实施步骤建议 + +1. **Usage 解析**:在三处解析函数中增加 DeepSeek cache 字段兜底。 +2. **单元测试**:补充 `UpdateCtxByUsage`、`SSEEvent.GetQuotaUsage`、`RawEvent.GetQuotaUsage` 的 DeepSeek 字段测试。 +3. **集成测试**:在 SC09 中新增非流式/流式 DeepSeek cache 字段扣费验证。 +4. **端到端验证**:重放 issue #101 中的请求,确认 `CacheReadTokens` 被正确填充;最终扣款是否匹配 DeepSeek 控制台还依赖 `model_prices` 与 provider tiers 的正确配置。 + +--- + +## 12. 参考资料 + +- [ai-gateway-api#101](https://github.com/rainway-ai-gateway/ai-gateway-api/issues/101) +- `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` +- `bfe/bfe_modules/mod_body_process/llm_util.go` +- `bfe/bfe_modules/mod_body_process/body_process.go` +- `bfe/docs/zh_cn/modifications/2026-08-22-cache-billing-support/design-changes.md` +- `bfe/docs/zh_cn/modifications/2026-08-25-tiered-pricing-support/design-changes.md` diff --git a/docs/zh_cn/sys_design/rmb_quota.md b/docs/zh_cn/sys_design/rmb_quota.md index 989a4d438..9f22112b9 100644 --- a/docs/zh_cn/sys_design/rmb_quota.md +++ b/docs/zh_cn/sys_design/rmb_quota.md @@ -15,7 +15,7 @@ - 把 `prompt_tokens` / `completion_tokens` 换算成人民币成本; - 对 `unit = "RMB"` 的配额计划扣减相应金额。 -v0.5 进一步引入 **cache** 与 **音频 token** 子项计费:后端返回的 `usage` 中可能包含 `cache_read_tokens`、`cache_write_tokens`、`audio_input_tokens`、`audio_output_tokens`,BFE 需要把这些子项从 `prompt_tokens` / `completion_tokens` 中剥离,并按各自价格分别计费。 +v0.5 进一步引入 **cache** 与 **音频 token** 子项计费:后端返回的 `usage` 中可能包含 `cache_read_tokens`、`cache_write_tokens`、`audio_input_tokens`、`audio_output_tokens`,BFE 需要把这些子项从 `prompt_tokens` / `completion_tokens` 中剥离,并按各自价格分别计费。针对 DeepSeek 等返回 `usage.prompt_cache_hit_tokens` 或 `usage.prompt_tokens_details.cached_tokens` 的模型,BFE 也将其识别为 cache read token。 v0.6 引入 **图像生成按次计费**:图像生成模型(如 `flux-2-pro`)不按 Token 计费,而是按实际生成的图像张数计费。`AIConf.ModelTable` 中新增 `output_cost_per_image` 价格字段,响应 `usage` 中新增 `image_count` 字段,BFE 按 `image_count × output_cost_per_image` 计算成本。请求路径 `/v1/images/generations` 被识别为 `image_generation` 模式,与 `chat` 模式使用独立的定价条目。 @@ -415,6 +415,21 @@ func (m *ModuleAITokenAuth) tokenReadResponseHandler(req *bfe_basic.Request, res 因此,到请求结束阶段,`tokenUsage.PromptTokens` 和 `tokenUsage.CompletionTokens` 已经就绪,无论流式还是非流式都可以统一计算 RMB 成本。 +#### DeepSeek cache 字段兜底 + +DeepSeek 在 usage 中使用与 OpenAI/Claude 不同的字段名表示缓存命中: + +- `usage.prompt_cache_hit_tokens` +- `usage.prompt_tokens_details.cached_tokens` + +BFE 在以下三处解析中,当 `cache_read_tokens` / `cache_read_input_tokens` 为 0 时,会依次 fallback 到上述 DeepSeek 字段: + +- `bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go`:`UpdateCtxByUsage`(非流式) +- `bfe_modules/mod_body_process/llm_util.go`:`SSEEvent.GetQuotaUsage`(SSE 流式) +- `bfe_modules/mod_body_process/body_process.go`:`RawEvent.GetQuotaUsage`(RawEvent 非流式) + +这样无论后端返回哪种字段名,`TokenUsage.CacheReadTokens` 都能被正确填充,后续 `calcChatCost` 按统一逻辑拆分计费。 + ### 7.5 请求结束阶段:`tokenRequestFinishHandler` `bfe/bfe_modules/mod_ai_token_auth/mod_ai_token_auth.go` @@ -644,7 +659,7 @@ cost = image_count * output_cost_per_image 在 `calcChatCost` 中,RMB 成本按如下优先级拆分: -1. **Cache read 从 prompt 中剥离**:若配置了 `cache_read_input_token_cost`,则 `normal_input = prompt_tokens - cache_read_tokens`。 +1. **Cache read 从 prompt 中剥离**:若配置了 `cache_read_input_token_cost`,则 `normal_input = prompt_tokens - cache_read_tokens`。`cache_read_tokens` 除直接读取 `usage.cache_read_tokens` 外,也支持 DeepSeek 的 `usage.prompt_cache_hit_tokens` 和 `usage.prompt_tokens_details.cached_tokens` 字段。 2. **Audio input 从剩余 normal input 中剥离**:若配置了 `input_cost_per_audio_token`,则 `audio_input_tokens` 按 audio 价格计费,其余仍按普通 input 价格计费。 3. **Audio output 从 completion 中剥离**:若配置了 `output_cost_per_audio_token`,则 `audio_output_tokens` 按 audio 价格计费,其余仍按普通 output 价格计费。 4. **Cache write 独立计费**:`cache_write_tokens` 不参与 prompt/completion 总量拆分,单独按 `cache_creation_input_token_cost` 计费。 @@ -982,9 +997,10 @@ return {yuan, frac} - 测试 fallback 场景:请求最终 fallback 到另一个 cluster,验证按最终 cluster + target_model 计费。 - 测试流式(SSE)场景:请求体带 `stream: true`,后端返回 SSE 并在最后一个 chunk 中携带 `usage`,验证 RMB 配额仍能正确扣减。 - 测试 cache/audio 子项计费场景:后端返回 `usage.cache_read_tokens`、`usage.cache_write_tokens`、`usage.audio_input_tokens`、`usage.audio_output_tokens`,验证成本按各子项价格拆分计算; + - 测试 DeepSeek cache 字段识别场景:后端返回 `usage.prompt_cache_hit_tokens` 或 `usage.prompt_tokens_details.cached_tokens`,验证 `CacheReadTokens` 被正确填充并按 `cache_read_input_token_cost` 拆分计费; - 测试图像生成按次计费场景:请求 `/v1/images/generations`,`ModelTable` 配置 `output_cost_per_image`,后端返回 `usage.image_count`,验证 RMB 配额按 `image_count × output_cost_per_image` 扣减,且 `total_token` 配额按 `image_count` 扣减。 - 测试分时段计费场景:`ModelTable` 配置 `Tiers` 与 `TierPrices`;分别在北京时间高峰时段(如周一 10:00)与非高峰时段(如周一 13:00 或周六 10:00)发起请求,验证 Redis 扣减金额分别按 `TierPrices.peak` 与默认 `Prices` 计算。 - - 测试分时段 + cache 命中组合场景:高峰时段且后端返回 `usage.cache_read_tokens`,验证缓存命中部分按 `TierPrices.peak.cache_read_input_token_cost` 计费,未命中部分按 `TierPrices.peak.input_cost_per_token` 计费。 + - 测试分时段 + cache 命中组合场景:高峰时段且后端返回 `usage.cache_read_tokens`(或 DeepSeek 的 `usage.prompt_cache_hit_tokens` / `usage.prompt_tokens_details.cached_tokens`),验证缓存命中部分按 `TierPrices.peak.cache_read_input_token_cost` 计费,未命中部分按 `TierPrices.peak.input_cost_per_token` 计费。 ## 11. 兼容性与注意事项 diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/sc09_rmb_tiered_pricing_test.go b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/sc09_rmb_tiered_pricing_test.go index 9fda4410f..1be900796 100644 --- a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/sc09_rmb_tiered_pricing_test.go +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/sc09_rmb_tiered_pricing_test.go @@ -61,6 +61,14 @@ var cacheStreamUsageResponse = "data: {\"choices\":[{\"delta\":{\"role\":\"assis "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n" + "data: {\"usage\":{\"prompt_tokens\":8000,\"completion_tokens\":1500,\"total_tokens\":9500,\"cache_read_tokens\":5000,\"cache_write_tokens\":1000}}\n\n" +var deepseekCacheUsageResponse = `{"usage":{"prompt_tokens":8000,"completion_tokens":1500,"total_tokens":9500,"prompt_cache_hit_tokens":5000}}` + +var deepseekCacheDetailsUsageResponse = `{"usage":{"prompt_tokens":8000,"completion_tokens":1500,"total_tokens":9500,"prompt_tokens_details":{"cached_tokens":5000}}}` + +var deepseekCacheStreamUsageResponse = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n" + + "data: {\"usage\":{\"prompt_tokens\":8000,\"completion_tokens\":1500,\"total_tokens\":9500,\"prompt_cache_hit_tokens\":5000}}\n\n" + // testEnv holds all resources for a single SC09 integration test. type testEnv struct { t *testing.T @@ -400,3 +408,79 @@ func TestTC04_RMBQuotaDeduction_Peak_Cache_Streaming(t *testing.T) { t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) } } + +// TestTC05 verifies that the DeepSeek cache field prompt_cache_hit_tokens is correctly +// recognized and billed using peak tier cache-aware prices for a non-streaming request. +func TestTC05_RMBQuotaDeduction_DeepSeekCacheField_NonStreaming(t *testing.T) { + aiConfs := map[string]*cluster_conf.AIConf{ + clusterTierPeak: tierPeakAIConf(), + } + e := newTestEnv(t, aiConfs) + defer e.Close() + + e.redis.SetQuota(redisKeyRMB, 10000000000) + e.backends[clusterTierPeak].Body = deepseekCacheUsageResponse + + resp, body, err := e.sendRequest(apiHost, cacheBody) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if e.backends[clusterTierPeak].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterTierPeak, e.backends[clusterTierPeak].Hits()) + } + + time.Sleep(500 * time.Millisecond) + remaining := e.redis.GetQuota(redisKeyRMB) + // normal_input = 8000 - 5000 = 3000 + // peak: input=200, cache_read=100, output=400 + // cost = 3000*200 + 5000*100 + 1500*400 = 1,700,000 + want := int64(10000000000 - 1700000) + if remaining != want { + e.logBFEException() + e.logBFEAccess() + t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) + } +} + +// TestTC06 verifies that the DeepSeek cache field prompt_tokens_details.cached_tokens is +// correctly recognized and billed using peak tier cache-aware prices for an SSE streaming request. +func TestTC06_RMBQuotaDeduction_DeepSeekCacheDetailsField_Streaming(t *testing.T) { + aiConfs := map[string]*cluster_conf.AIConf{ + clusterTierPeak: tierPeakAIConf(), + } + e := newTestEnv(t, aiConfs) + defer e.Close() + + e.redis.SetQuota(redisKeyRMB, 10000000000) + e.backends[clusterTierPeak].ResponseHeaders = map[string]string{"Content-Type": "text/event-stream"} + e.backends[clusterTierPeak].Body = "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n" + + "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n" + + "data: {\"usage\":{\"prompt_tokens\":8000,\"completion_tokens\":1500,\"total_tokens\":9500,\"prompt_tokens_details\":{\"cached_tokens\":5000}}}\n\n" + + resp, body, err := e.sendRequest(apiHost, cacheStreamBody) + if err != nil { + t.Fatalf("send request failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + e.logBFEException() + t.Fatalf("expected status 200, got %d, body: %s", resp.StatusCode, body) + } + + if e.backends[clusterTierPeak].Hits() != 1 { + t.Fatalf("expected 1 hit on %s, got %d", clusterTierPeak, e.backends[clusterTierPeak].Hits()) + } + + time.Sleep(500 * time.Millisecond) + remaining := e.redis.GetQuota(redisKeyRMB) + want := int64(10000000000 - 1700000) + if remaining != want { + e.logBFEException() + e.logBFEAccess() + t.Fatalf("remaining quota = %d, want %d, response body: %s", remaining, want, body) + } +} diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-05-peak-tier-DeepSeek\347\274\223\345\255\230\345\221\275\344\270\255\345\255\227\346\256\265\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-05-peak-tier-DeepSeek\347\274\223\345\255\230\345\221\275\344\270\255\345\255\227\346\256\265\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" new file mode 100644 index 000000000..846b23161 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-05-peak-tier-DeepSeek\347\274\223\345\255\230\345\221\275\344\270\255\345\255\227\346\256\265\351\235\236\346\265\201\345\274\217\350\256\241\350\264\271.md" @@ -0,0 +1,88 @@ +# TC-05 `peak` tier + DeepSeek 缓存命中字段非流式计费 + +## 用例编号与名称 + +TC-05 `peak` tier + DeepSeek 缓存命中字段非流式计费 + +## 所属场景 + +SC09 RMB 分时段计费 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证 BFE 能正确识别 DeepSeek 返回的 `usage.prompt_cache_hit_tokens` 字段,并在 `peak` tier 下按 cache-aware 公式扣减 RMB 配额。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动,并预置 `quota:plan_rmb = 10000000000`(100 元)。 +3. mock 后端 `cluster_rmb` 已启动,返回 200 与如下 body: + ```json + { + "usage": { + "prompt_tokens": 8000, + "completion_tokens": 1500, + "total_tokens": 9500, + "prompt_cache_hit_tokens": 5000 + } + } + ``` +4. 临时 BFE 配置已加载,`cluster_rmb` 配置 `ModelTable`: + - `Tiers` 中 `peak` 覆盖全周全天; + - `deepseek-chat` 的默认 input/output/cache_read 价格分别为 `0.000001` / `0.000002` / `0.0000005`; + - `deepseek-chat` 的 `TierPrices.peak` input/output/cache_read 价格分别为 `0.000002` / `0.000004` / `0.000001`。 +5. `ak_user_a` 绑定 RMB 配额计划 `plan_rmb`。 + +## 配置构造 + +- `cluster_rmb.AIConf.ModelTable.Tiers[0]`: + - `Name`: `peak` + - `TimeRanges[0].Weekdays`: `[0,1,2,3,4,5,6]` + - `TimeRanges[0].Start`: `00:00` + - `TimeRanges[0].End`: `23:59` +- `cluster_rmb.AIConf.ModelTable.Models[0]`: + - `Model`: `deepseek-chat` + - `Mode`: `chat` + - `Prices.input_cost_per_token`: `0.000001` + - `Prices.output_cost_per_token`: `0.000002` + - `Prices.cache_read_input_token_cost`: `0.0000005` + - `TierPrices.peak.input_cost_per_token`: `0.000002` + - `TierPrices.peak.output_cost_per_token`: `0.000004` + - `TierPrices.peak.cache_read_input_token_cost`: `0.000001` +- `plan_rmb`: + - `Unit`: `RMB` + - `Quota`: `10000000000` + - `RedisKey`: `quota:plan_rmb` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `tier.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"deepseek-chat"}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_rmb` 收到 1 次命中。 +- BFE 解析 `prompt_cache_hit_tokens = 5000` 作为 `CacheReadTokens`。 +- Redis 中 `quota:plan_rmb` 的余额变为: + - `normal_input = 8000 - 5000 = 3000` + - 扣减金额 = `3000 * 200 + 5000 * 100 + 1500 * 400 = 1700000` + - 剩余 = `10000000000 - 1700000 = 9999830000` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 diff --git "a/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-06-peak-tier-DeepSeek\347\274\223\345\255\230\345\221\275\344\270\255\345\255\227\346\256\265SSE\346\265\201\345\274\217\350\256\241\350\264\271.md" "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-06-peak-tier-DeepSeek\347\274\223\345\255\230\345\221\275\344\270\255\345\255\227\346\256\265SSE\346\265\201\345\274\217\350\256\241\350\264\271.md" new file mode 100644 index 000000000..2d4db2e59 --- /dev/null +++ "b/tests/integration/\346\265\213\350\257\225\350\256\276\350\256\241\346\226\207\346\241\243/scenario-SC09-RMB\345\210\206\346\227\266\346\256\265\350\256\241\350\264\271/TC-06-peak-tier-DeepSeek\347\274\223\345\255\230\345\221\275\344\270\255\345\255\227\346\256\265SSE\346\265\201\345\274\217\350\256\241\350\264\271.md" @@ -0,0 +1,87 @@ +# TC-06 `peak` tier + DeepSeek 缓存命中字段 SSE 流式计费 + +## 用例编号与名称 + +TC-06 `peak` tier + DeepSeek 缓存命中字段 SSE 流式计费 + +## 所属场景 + +SC09 RMB 分时段计费 + +## 版本声明 + +- `bfe`:当前源码版本 + +## 测试目的 + +验证在 SSE 流式响应的最终 chunk 中,BFE 能正确识别 DeepSeek 返回的 `usage.prompt_tokens_details.cached_tokens` 字段,并在 `peak` tier 下按 cache-aware 公式扣减 RMB 配额。 + +## 运行模式 + +单组件模式:仅启动真实 `bfe` 进程与嵌入式 Redis。 + +## 前置条件 + +1. 已编译 `bfe` 可执行文件。 +2. 嵌入式 Redis 已启动,并预置 `quota:plan_rmb = 10000000000`(100 元)。 +3. mock 后端 `cluster_rmb` 已启动,返回 200,响应头包含 `Content-Type: text/event-stream`,body 为 SSE 格式: + ```text + data: {"choices":[{"delta":{"role":"assistant"}}]} + + data: {"choices":[{"delta":{"content":"hello"}}]} + + data: {"usage":{"prompt_tokens":8000,"completion_tokens":1500,"total_tokens":9500,"prompt_tokens_details":{"cached_tokens":5000}}} + + ``` +4. 临时 BFE 配置已加载,`cluster_rmb` 配置 `ModelTable`: + - `Tiers` 中 `peak` 覆盖全周全天; + - `deepseek-chat` 的默认 input/output/cache_read 价格分别为 `0.000001` / `0.000002` / `0.0000005`; + - `deepseek-chat` 的 `TierPrices.peak` input/output/cache_read 价格分别为 `0.000002` / `0.000004` / `0.000001`。 +5. `bfe.conf` 已启用 `mod_body_process`,确保 SSE 流式响应被处理。 +6. `ak_user_a` 绑定 RMB 配额计划 `plan_rmb`。 + +## 配置构造 + +- `cluster_rmb.AIConf.ModelTable.Tiers[0]`: + - `Name`: `peak` + - `TimeRanges[0].Weekdays`: `[0,1,2,3,4,5,6]` + - `TimeRanges[0].Start`: `00:00` + - `TimeRanges[0].End`: `23:59` +- `cluster_rmb.AIConf.ModelTable.Models[0]`: + - `Model`: `deepseek-chat` + - `Mode`: `chat` + - `Prices.input_cost_per_token`: `0.000001` + - `Prices.output_cost_per_token`: `0.000002` + - `Prices.cache_read_input_token_cost`: `0.0000005` + - `TierPrices.peak.input_cost_per_token`: `0.000002` + - `TierPrices.peak.output_cost_per_token`: `0.000004` + - `TierPrices.peak.cache_read_input_token_cost`: `0.000001` +- `plan_rmb`: + - `Unit`: `RMB` + - `Quota`: `10000000000` + - `RedisKey`: `quota:plan_rmb` + +## BFE 请求 + +发送 1 次 POST 请求: + +| 字段 | 值 | +|------|-----| +| Host | `tier.example.org` | +| Path | `/v1/chat/completions` | +| Authorization | `Bearer ak_user_a` | +| Body | `{"model":"deepseek-chat","stream":true}` | + +## 预期结果 + +- 响应状态码:200。 +- `cluster_rmb` 收到 1 次命中。 +- BFE 解析 `prompt_tokens_details.cached_tokens = 5000` 作为 `CacheReadTokens`。 +- Redis 中 `quota:plan_rmb` 的余额变为: + - `normal_input = 8000 - 5000 = 3000` + - 扣减金额 = `3000 * 200 + 5000 * 100 + 1500 * 400 = 1700000` + - 剩余 = `10000000000 - 1700000 = 9999830000` + +## 清理 + +停止 `bfe` 进程、mock 后端与嵌入式 Redis,删除临时目录。 From 610184181729ca760eb3158fc69f5cce7822f639 Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Thu, 27 Aug 2026 18:46:30 +0800 Subject: [PATCH 16/18] fix(cluster_conf): preserve 8-decimal precision in PriceMap/TierPriceMap JSON serialization (#1338) - Add custom MarshalJSON for PriceMap and TierPriceMap to output decimal notation instead of scientific notation (e.g. 0.0000015 instead of 1.5e-6). - Update existing tests to use the new PriceMap/TierPriceMap types. Fixes https://github.com/bfenetworks/bfe/issues/1338 --- .../cluster_conf/cluster_conf_load.go | 83 ++++++++++++++++++- .../cluster_conf/cluster_conf_load_test.go | 46 +++++----- .../mod_ai_token_auth_test.go | 6 +- .../sc02_multi_api_key_test.go | 2 +- .../sc03_rmb_quota_test.go | 10 +-- .../sc05_access_log_ai_fields_test.go | 10 +-- .../sc09_rmb_tiered_pricing_test.go | 4 +- 7 files changed, 120 insertions(+), 41 deletions(-) diff --git a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go index e55db935f..7c4924d1e 100644 --- a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go +++ b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load.go @@ -17,8 +17,10 @@ package cluster_conf import ( + "bytes" "crypto/x509" "encoding/pem" + stdjson "encoding/json" "errors" "fmt" "os" @@ -165,6 +167,83 @@ type PriceTier struct { TimeRanges []TimeRange // hit any range means the request belongs to this tier } +// PriceMap is a map of price keys to their numeric values. +// It marshals to JSON using decimal notation instead of scientific notation. +type PriceMap map[string]float64 + +// MarshalJSON serializes PriceMap using decimal notation for all values. +func (p PriceMap) MarshalJSON() ([]byte, error) { + if p == nil { + return []byte("null"), nil + } + var buf bytes.Buffer + buf.WriteByte('{') + first := true + for k, v := range p { + if !first { + buf.WriteByte(',') + } + first = false + keyBytes, err := stdjson.Marshal(k) + if err != nil { + return nil, err + } + buf.Write(keyBytes) + buf.WriteByte(':') + buf.WriteString(strconv.FormatFloat(v, 'f', -1, 64)) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +// TierPriceMap is a map of tier names to PriceMap values. +// It marshals to JSON using decimal notation instead of scientific notation. +type TierPriceMap map[string]map[string]float64 + +// MarshalJSON serializes TierPriceMap using decimal notation for all nested values. +func (t TierPriceMap) MarshalJSON() ([]byte, error) { + if t == nil { + return []byte("null"), nil + } + var buf bytes.Buffer + buf.WriteByte('{') + first := true + for tier, prices := range t { + if !first { + buf.WriteByte(',') + } + first = false + tierBytes, err := stdjson.Marshal(tier) + if err != nil { + return nil, err + } + buf.Write(tierBytes) + buf.WriteByte(':') + if prices == nil { + buf.WriteString("null") + continue + } + innerFirst := true + buf.WriteByte('{') + for k, v := range prices { + if !innerFirst { + buf.WriteByte(',') + } + innerFirst = false + keyBytes, err := stdjson.Marshal(k) + if err != nil { + return nil, err + } + buf.Write(keyBytes) + buf.WriteByte(':') + buf.WriteString(strconv.FormatFloat(v, 'f', -1, 64)) + } + buf.WriteByte('}') + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + // ModelPrice represents a single model pricing entry in AIConf.ModelTable type ModelPrice struct { Provider string @@ -174,8 +253,8 @@ type ModelPrice struct { Capabilities []string SupportedParameters []string Limits map[string]interface{} - Prices map[string]float64 // default prices - TierPrices map[string]map[string]float64 // tier name -> price table + Prices PriceMap // default prices + TierPrices TierPriceMap // tier name -> price table Metadata map[string]interface{} // pricesInt and tierPricesInt are built at config load time. diff --git a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go index 0206b8ba1..10d87c7f4 100644 --- a/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go +++ b/bfe_config/bfe_cluster_conf/cluster_conf/cluster_conf_load_test.go @@ -128,7 +128,7 @@ func TestModelTableCheck(t *testing.T) { { Model: "deepseek-chat", Mode: "chat", - Prices: map[string]float64{ + Prices: PriceMap{ PriceInputCostPerToken: 0.000001, PriceOutputCostPerToken: 0.000002, }, @@ -160,7 +160,7 @@ func TestModelTableCheck(t *testing.T) { { Model: "claude-opus-4-6", Mode: "chat", - Prices: map[string]float64{ + Prices: PriceMap{ PriceInputCostPerToken: 0.000004525, PriceOutputCostPerToken: 0.000022625, PriceCacheReadInputTokenCost: 0.0000004525, @@ -191,7 +191,7 @@ func TestModelTableCheck(t *testing.T) { { Model: "gpt-audio-1.5", Mode: "chat", - Prices: map[string]float64{ + Prices: PriceMap{ PriceInputCostPerToken: 0.00000178, PriceOutputCostPerToken: 0.00000715, PriceInputCostPerAudioToken: 0.00002288, @@ -219,7 +219,7 @@ func TestModelTableCheck(t *testing.T) { table := &ModelTable{ Currency: "USD", Models: []ModelPrice{ - {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + {Model: "m", Mode: "chat", Prices: PriceMap{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, }, } if err := ModelTableCheck(table); err == nil { @@ -231,7 +231,7 @@ func TestModelTableCheck(t *testing.T) { table := &ModelTable{ Currency: "RMB", Models: []ModelPrice{ - {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: -1, PriceOutputCostPerToken: 1}}, + {Model: "m", Mode: "chat", Prices: PriceMap{PriceInputCostPerToken: -1, PriceOutputCostPerToken: 1}}, }, } if err := ModelTableCheck(table); err == nil { @@ -243,8 +243,8 @@ func TestModelTableCheck(t *testing.T) { table := &ModelTable{ Currency: "RMB", Models: []ModelPrice{ - {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, - {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 2, PriceOutputCostPerToken: 2}}, + {Model: "m", Mode: "chat", Prices: PriceMap{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + {Model: "m", Mode: "chat", Prices: PriceMap{PriceInputCostPerToken: 2, PriceOutputCostPerToken: 2}}, }, } if err := ModelTableCheck(table); err == nil { @@ -256,7 +256,7 @@ func TestModelTableCheck(t *testing.T) { table := &ModelTable{ Currency: "RMB", Models: []ModelPrice{ - {Model: "", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + {Model: "", Mode: "chat", Prices: PriceMap{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, }, } if err := ModelTableCheck(table); err == nil { @@ -343,7 +343,7 @@ func TestModelTableCheck_Tiers(t *testing.T) { { Model: "m", Mode: "chat", - Prices: map[string]float64{ + Prices: PriceMap{ PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1, }, @@ -366,7 +366,7 @@ func TestModelTableCheck_Tiers(t *testing.T) { Currency: "RMB", Tiers: []PriceTier{validPeakTier}, Models: []ModelPrice{ - {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + {Model: "m", Mode: "chat", Prices: PriceMap{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, }, } if err := ModelTableCheck(table); err != nil { @@ -383,7 +383,7 @@ func TestModelTableCheck_Tiers(t *testing.T) { TimeZone: "Mars/Phobos", Tiers: []PriceTier{validPeakTier}, Models: []ModelPrice{ - {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + {Model: "m", Mode: "chat", Prices: PriceMap{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, }, } if err := ModelTableCheck(table); err == nil { @@ -398,7 +398,7 @@ func TestModelTableCheck_Tiers(t *testing.T) { {Name: "off_peak", TimeRanges: []TimeRange{{Start: "00:00", End: "09:00"}}}, }, Models: []ModelPrice{ - {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + {Model: "m", Mode: "chat", Prices: PriceMap{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, }, } if err := ModelTableCheck(table); err == nil { @@ -411,7 +411,7 @@ func TestModelTableCheck_Tiers(t *testing.T) { Currency: "RMB", Tiers: []PriceTier{{Name: "", TimeRanges: []TimeRange{{Start: "00:00", End: "09:00"}}}}, Models: []ModelPrice{ - {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + {Model: "m", Mode: "chat", Prices: PriceMap{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, }, } if err := ModelTableCheck(table); err == nil { @@ -424,7 +424,7 @@ func TestModelTableCheck_Tiers(t *testing.T) { Currency: "RMB", Tiers: []PriceTier{{Name: "peak"}}, Models: []ModelPrice{ - {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + {Model: "m", Mode: "chat", Prices: PriceMap{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, }, } if err := ModelTableCheck(table); err == nil { @@ -439,7 +439,7 @@ func TestModelTableCheck_Tiers(t *testing.T) { {Name: "peak", TimeRanges: []TimeRange{{Weekdays: []int{7}, Start: "09:00", End: "12:00"}}}, }, Models: []ModelPrice{ - {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + {Model: "m", Mode: "chat", Prices: PriceMap{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, }, } if err := ModelTableCheck(table); err == nil { @@ -454,7 +454,7 @@ func TestModelTableCheck_Tiers(t *testing.T) { {Name: "peak", TimeRanges: []TimeRange{{Weekdays: []int{1}, Start: "12:00", End: "09:00"}}}, }, Models: []ModelPrice{ - {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + {Model: "m", Mode: "chat", Prices: PriceMap{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, }, } if err := ModelTableCheck(table); err == nil { @@ -475,7 +475,7 @@ func TestModelTableCheck_Tiers(t *testing.T) { }, }, Models: []ModelPrice{ - {Model: "m", Mode: "chat", Prices: map[string]float64{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, + {Model: "m", Mode: "chat", Prices: PriceMap{PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1}}, }, } if err := ModelTableCheck(table); err == nil { @@ -490,11 +490,11 @@ func TestModelTableCheck_Tiers(t *testing.T) { { Model: "m", Mode: "chat", - Prices: map[string]float64{ + Prices: PriceMap{ PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1, }, - TierPrices: map[string]map[string]float64{ + TierPrices: TierPriceMap{ "off_peak": {PriceInputCostPerToken: 2}, }, }, @@ -512,11 +512,11 @@ func TestModelTableCheck_Tiers(t *testing.T) { { Model: "m", Mode: "chat", - Prices: map[string]float64{ + Prices: PriceMap{ PriceInputCostPerToken: 1, PriceOutputCostPerToken: 1, }, - TierPrices: map[string]map[string]float64{ + TierPrices: TierPriceMap{ "peak": {PriceInputCostPerToken: -1}, }, }, @@ -579,11 +579,11 @@ func TestGetPriceInt(t *testing.T) { { Model: "m", Mode: "chat", - Prices: map[string]float64{ + Prices: PriceMap{ PriceInputCostPerToken: 1, PriceOutputCostPerToken: 2, }, - TierPrices: map[string]map[string]float64{ + TierPrices: TierPriceMap{ "peak": { PriceInputCostPerToken: 10, }, diff --git a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go index f66af8416..4570a27db 100644 --- a/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go +++ b/bfe_modules/mod_ai_token_auth/mod_ai_token_auth_test.go @@ -781,7 +781,7 @@ func buildTestClusterConfWithAudio(model string, inputCost, outputCost, cacheRea Model: model, BaseModel: model, Mode: "chat", - Prices: map[string]float64{ + Prices: cluster_conf.PriceMap{ cluster_conf.PriceInputCostPerToken: inputCost, cluster_conf.PriceOutputCostPerToken: outputCost, cluster_conf.PriceCacheReadInputTokenCost: cacheReadCost, @@ -1594,12 +1594,12 @@ func buildTestClusterConfWithTiers(model string, inputCost, outputCost, cacheRea Model: model, BaseModel: model, Mode: "chat", - Prices: map[string]float64{ + Prices: cluster_conf.PriceMap{ cluster_conf.PriceInputCostPerToken: inputCost, cluster_conf.PriceOutputCostPerToken: outputCost, cluster_conf.PriceCacheReadInputTokenCost: cacheReadCost, }, - TierPrices: map[string]map[string]float64{ + TierPrices: cluster_conf.TierPriceMap{ "peak": { cluster_conf.PriceInputCostPerToken: peakInputCost, cluster_conf.PriceOutputCostPerToken: peakOutputCost, diff --git a/tests/integration/implementation/scenario-SC02-multi-api-key/sc02_multi_api_key_test.go b/tests/integration/implementation/scenario-SC02-multi-api-key/sc02_multi_api_key_test.go index 264d036ba..94dfb0c91 100644 --- a/tests/integration/implementation/scenario-SC02-multi-api-key/sc02_multi_api_key_test.go +++ b/tests/integration/implementation/scenario-SC02-multi-api-key/sc02_multi_api_key_test.go @@ -495,7 +495,7 @@ func TestTC08_AIConfExtendedFields(t *testing.T) { "max_input_tokens": 128000, "max_output_tokens": 8192, }, - Prices: map[string]float64{ + Prices: cluster_conf.PriceMap{ "input_cost_per_token": 0.000002, "output_cost_per_token": 0.000008, }, diff --git a/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go b/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go index 5d34c3046..cc835308e 100644 --- a/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go +++ b/tests/integration/implementation/scenario-SC03-rmb-quota/sc03_rmb_quota_test.go @@ -238,7 +238,7 @@ func defaultRMBAIConf() *cluster_conf.AIConf { Limits: map[string]interface{}{ "context_window": 128000, }, - Prices: map[string]float64{ + Prices: cluster_conf.PriceMap{ "input_cost_per_token": 0.000001, "output_cost_per_token": 0.000002, }, @@ -253,7 +253,7 @@ func defaultRMBAIConf() *cluster_conf.AIConf { Limits: map[string]interface{}{ "context_window": 128000, }, - Prices: map[string]float64{ + Prices: cluster_conf.PriceMap{ "input_cost_per_token": 0.00000452, "output_cost_per_token": 0.00002262, "cache_read_input_token_cost": 0.00000045, @@ -270,7 +270,7 @@ func defaultRMBAIConf() *cluster_conf.AIConf { Limits: map[string]interface{}{ "context_window": 128000, }, - Prices: map[string]float64{ + Prices: cluster_conf.PriceMap{ "input_cost_per_token": 0.00000178, "output_cost_per_token": 0.00000715, "input_cost_per_audio_token": 0.00002288, @@ -284,7 +284,7 @@ func defaultRMBAIConf() *cluster_conf.AIConf { func fallbackRMBAIConf() *cluster_conf.AIConf { conf := defaultRMBAIConf() - conf.ModelTable.Models[0].Prices = map[string]float64{ + conf.ModelTable.Models[0].Prices = cluster_conf.PriceMap{ "input_cost_per_token": 0.000003, "output_cost_per_token": 0.000004, } @@ -303,7 +303,7 @@ func imageGenerationAIConf() *cluster_conf.AIConf { Limits: map[string]interface{}{ "context_window": 128000, }, - Prices: map[string]float64{ + Prices: cluster_conf.PriceMap{ "output_cost_per_image": 0.03, }, }) diff --git a/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go b/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go index 37e7118b3..df67737c7 100644 --- a/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go +++ b/tests/integration/implementation/scenario-SC05-access-log-ai-fields/sc05_access_log_ai_fields_test.go @@ -249,7 +249,7 @@ func defaultRMBAIConf() *cluster_conf.AIConf { Limits: map[string]interface{}{ "context_window": 128000, }, - Prices: map[string]float64{ + Prices: cluster_conf.PriceMap{ "input_cost_per_token": 0.000001, "output_cost_per_token": 0.000002, }, @@ -272,7 +272,7 @@ func multiKeyRMBAIConf() *cluster_conf.AIConf { func fallbackRMBAIConf() *cluster_conf.AIConf { conf := defaultRMBAIConf() conf.Provider = "mock-provider-fallback" - conf.ModelTable.Models[0].Prices = map[string]float64{ + conf.ModelTable.Models[0].Prices = cluster_conf.PriceMap{ "input_cost_per_token": 0.000003, "output_cost_per_token": 0.000004, } @@ -281,7 +281,7 @@ func fallbackRMBAIConf() *cluster_conf.AIConf { func cacheRMBAIConf() *cluster_conf.AIConf { conf := defaultRMBAIConf() - conf.ModelTable.Models[0].Prices = map[string]float64{ + conf.ModelTable.Models[0].Prices = cluster_conf.PriceMap{ "input_cost_per_token": 0.000001, "output_cost_per_token": 0.000002, "cache_read_input_token_cost": 0.0000005, @@ -302,7 +302,7 @@ func audioRMBAIConf() *cluster_conf.AIConf { Limits: map[string]interface{}{ "context_window": 128000, }, - Prices: map[string]float64{ + Prices: cluster_conf.PriceMap{ "input_cost_per_token": 0.00000178, "output_cost_per_token": 0.00000715, "input_cost_per_audio_token": 0.00002288, @@ -324,7 +324,7 @@ func imageGenerationAIConf() *cluster_conf.AIConf { Limits: map[string]interface{}{ "context_window": 128000, }, - Prices: map[string]float64{ + Prices: cluster_conf.PriceMap{ "output_cost_per_image": 0.03, }, }) diff --git a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/sc09_rmb_tiered_pricing_test.go b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/sc09_rmb_tiered_pricing_test.go index 1be900796..487cc09af 100644 --- a/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/sc09_rmb_tiered_pricing_test.go +++ b/tests/integration/implementation/scenario-SC09-rmb-tiered-pricing/sc09_rmb_tiered_pricing_test.go @@ -240,12 +240,12 @@ func tierPeakAIConf() *cluster_conf.AIConf { Limits: map[string]interface{}{ "context_window": 128000, }, - Prices: map[string]float64{ + Prices: cluster_conf.PriceMap{ "input_cost_per_token": 0.000001, "output_cost_per_token": 0.000002, "cache_read_input_token_cost": 0.0000005, }, - TierPrices: map[string]map[string]float64{ + TierPrices: cluster_conf.TierPriceMap{ "peak": { "input_cost_per_token": 0.000002, "output_cost_per_token": 0.000004, From a80153284ce9bc0535b3661091d536c218e88a10 Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Fri, 28 Aug 2026 11:14:51 +0800 Subject: [PATCH 17/18] doc: add BFE AI gateway error codes documentation Signed-off-by: zhangmiao --- docs/zh_cn/SUMMARY.md | 1 + docs/zh_cn/sys_design/ai_error_codes.md | 154 ++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 docs/zh_cn/sys_design/ai_error_codes.md diff --git a/docs/zh_cn/SUMMARY.md b/docs/zh_cn/SUMMARY.md index df8dcc61b..e8397ff38 100644 --- a/docs/zh_cn/SUMMARY.md +++ b/docs/zh_cn/SUMMARY.md @@ -124,6 +124,7 @@ * [BFE 多 API-Key 支持](sys_design/multi_api_key.md) * [BFE AI 访问日志可观测字段设计](sys_design/ai_access_log_fields.md) * [AI 限流 Redis 计数器 key 稳定性设计](sys_design/ai_rate_limit_redis_key.md) + * [BFE AI 网关错误码说明](sys_design/ai_error_codes.md) * [模块开发介绍](development/module/overview.md) * [BFE回调机制说明](development/module/bfe_callback.md) * [如何开发模块](development/module/how_to_write_module.md) diff --git a/docs/zh_cn/sys_design/ai_error_codes.md b/docs/zh_cn/sys_design/ai_error_codes.md new file mode 100644 index 000000000..20e20aec5 --- /dev/null +++ b/docs/zh_cn/sys_design/ai_error_codes.md @@ -0,0 +1,154 @@ +# BFE AI 网关错误码说明 + +## 1. 背景 + +BFE 作为 AI 网关时,会在请求处理的各个阶段(认证、路由、限流、转发等)对异常情况进行统一封装,并返回结构化的 JSON 错误响应。本文档给出 BFE 数据面当前实际返回的错误码、响应格式、触发条件及排查建议。 + +BFE 相关实现参考了 AI 网关错误返回码方案的整体思路,并依据实际模块划分进行了落地。错误码定义位于 `bfe_basic/request_ai_basic.go`,主要由以下模块产生: + +- `mod_ai_token_auth`:API Key 认证与配额校验 +- `mod_ai_rate_limit`:RPM / TPM / 并发限流 +- `bfe_server/reverseproxy.go`:AI 请求转发与协议适配 + +## 2. HTTP 状态码与错误码映射表 + +### 2.1 认证与准入层 + +由 `mod_ai_token_auth` 在 `HandleFoundProduct` 阶段校验 API Key 时返回。 + +| 场景 | HTTP 状态码 | `error.code` | `error.type` | `error.message` 示例 | 触发条件 | +|------|-------------|--------------|--------------|---------------------|----------| +| 产品线不存在 | 400 | `INVALID_REQUEST` | `invalid_request_error` | "product not found." | 请求未匹配到 Product | +| 无 API Key | 401 | `NO_API_KEY` | `authentication_error` | "no api key in request." | 请求未携带 `Authorization: Bearer ` 或 `x-api-key` 头 | +| API Key 不存在 | 401 | `INVALID_API_KEY` | `authentication_error` | "Invalid API key: ak-xxxxx. Key not found in system." | 请求携带的 API Key 在系统中不存在 | +| API Key 已禁用 | 403 | `KEY_DISABLED` | `authentication_error` | "Invalid API key: ak-xxxxx. disabled." | API Key 的 `Enabled` 字段为 false | +| API Key 已过期 | 403 | `KEY_EXPIRED` | `authentication_error` | "Invalid API key: ak-xxxxx. expired." | API Key 的 `ExpiredTime` 已到期且非 -1 | +| 客户端 IP 不在白名单 | 403 | `SUBNET_NOT_ALLOWED` | `authentication_error` | "Client IP not in subnet of key ak-xxxxx." | 客户端 IP 不在 API Key 允许的子网范围内 | +| 请求模型不在白名单 | 400 | `MODEL_NOT_ALLOWED` | `invalid_request_error` | "Model gpt-5 not allowed by key ak-xxxxx." | 请求的模型不在 API Key 的允许列表中,或被加入黑名单 | + +### 2.2 限流检查层 + +由 `mod_ai_rate_limit` 在 `HandleFoundProduct` 阶段根据 Redis 计数器或本地并发限制返回。 + +| 场景 | HTTP 状态码 | `error.code` | `error.type` | `error.message` 示例 | 触发条件 | +|------|-------------|--------------|--------------|---------------------|----------| +| RPM 窗口请求数超限 | 429 | `RPM_LIMIT_EXCEEDED` | `rate_limit_error` | "Rate limit exceeded for policy rlp-0001." | 固定窗口计数器:`used_requests + 1 > max_requests` | +| TPM 窗口 Token 数超限 | 429 | `TPM_LIMIT_EXCEEDED` | `rate_limit_error` | "Rate limit exceeded for policy rlp-0001." | 滑动窗口:`used_tokens + request_tokens > max_tokens` | +| 并发请求数超限 | 429 | `CONCURRENCY_LIMIT_EXCEEDED` | `rate_limit_error` | "Rate limit exceeded for policy rlp-0001." | 当前并发请求数达到或超过 `max_concurrency` | +| Redis 限流访问失败 | 500 | `RATE_LIMIT_REDIS_ERROR` | `rate_limit_error` | "Rate limit exceeded for policy rlp-0001." | 访问 Redis 计数器失败,且配置 `IsRejectOnRedisError` 为 true | + +### 2.3 配额扣减层 + +由 `mod_ai_token_auth` 在请求进入时进行预扣配额校验返回。 + +| 场景 | HTTP 状态码 | `error.code` | `error.type` | `error.message` 示例 | 触发条件 | +|------|-------------|--------------|--------------|---------------------|----------| +| 配额包余额不足 | 429 | `QUOTA_EXHAUSTED` | `quota_error` | "Quota plan qplan-0001 exhausted." | Quota Plan 余额已扣减到 0 | +| 配额包已过期 | 429 | `QUOTA_EXPIRED` | `quota_error` | "Quota plan qplan-0001 expired." | `plan.ExpiredTime` 已到期且非 -1 | +| 配额 Redis 查询异常 | 500 | `INTERNAL_QUOTA_ERROR` | `internal_error` | "Internal error during quota deduction for plan qplan-0001: ." | 查询 Redis 配额余额时发生内部错误 | + +### 2.4 转发与协议适配层 + +由 `bfe_server/reverseproxy.go` 在 AI 请求转发阶段返回。 + +| 场景 | HTTP 状态码 | `error.code` | `error.type` | `error.message` 示例 | 触发条件 | +|------|-------------|--------------|--------------|---------------------|----------| +| 提供商协议不匹配 | 400 | `PROVIDER_PROTOCOL_MISMATCH` | `invalid_request_error` | "request protocol anthropic not supported by cluster provider (model_protocols=[openai])." | 请求的 `AuthStyle` 不在目标集群 `AIConf.ModelProtocols` 支持范围内 | + +## 3. 响应体结构规范 + +### 3.1 标准错误响应体(OpenAI 兼容格式) + +所有数据面错误响应采用统一顶层结构: + +```json +{ + "error": { + "code": "QUOTA_EXHAUSTED", + "type": "quota_error", + "message": "Quota plan qplan-0001 exhausted.", + "param": null, + "details": { + "api_key": "ak-2v8x9k3m7p", + "key_id": "key-001", + "quota_plan_id": "qplan-0001", + "limit_type": "api_key_quota", + "model": "gpt-4", + "retry_after_seconds": 0 + } + } +} +``` + +### 3.2 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `error` | object | 是 | 错误信息根对象 | +| `error.code` | string | 是 | 机器可读的错误子类型,采用大写下划线命名法 | +| `error.type` | string | 是 | 错误大类:`authentication_error`、`invalid_request_error`、`rate_limit_error`、`quota_error`、`internal_error` | +| `error.message` | string | 是 | 人类可读的错误描述,使用英文 | +| `error.param` | string / null | 否 | 出错的参数名,与 OpenAI 标准兼容,无具体参数时返回 null | +| `error.details` | object | 否 | 结构化详情,供客户端和自动化工具做精确决策 | +| `error.details.api_key` | string | 否 | API Key 标识(原始 key 值) | +| `error.details.key_id` | string | 否 | API Key 内部标识 | +| `error.details.quota_plan_id` | string | 否 | 配额计划 ID(配额类错误) | +| `error.details.limit_type` | string | 否 | 配额/限流维度:`api_key_quota`、`rpm`、`tpm`、`concurrency` | +| `error.details.model` | string | 否 | 请求中的模型标识 | +| `error.details.retry_after_seconds` | int | 否 | 建议等待秒数(限流类错误预留) | + +## 4. 预留错误码 + +以下错误码已在 `bfe_basic/request_ai_basic.go` 中定义,用于后续扩展场景,当前版本尚未主动返回: + +| 错误码 | HTTP 状态码 | 错误类型 | 规划用途 | +|--------|-------------|----------|----------| +| `QUOTA_PACKAGE_DISABLED` | 429 | `quota_error` | Quota 套餐被禁用 | +| `QUOTA_MODEL_MISMATCH` | 429 | `quota_error` | 请求模型与配额套餐可用模型不匹配 | +| `QUOTA_PLAN_FAILED` | 429 | `quota_error` | 配额计划执行失败 | +| `CONTEXT_LENGTH_EXCEEDED` | 400 | `invalid_request_error` | 上下文长度超过模型限制 | +| `CONTENT_FILTERED` | 400 | `invalid_request_error` | 请求或响应内容被安全策略过滤 | +| `INVALID_REQUEST_BODY` | 400 | `invalid_request_error` | 请求体非法 | +| `MODEL_PARAM_MISSING` | 400 | `invalid_request_error` | 缺少必要的模型参数 | +| `MODEL_INTERNAL_ERROR` | 500 | `internal_error` | 模型服务返回内部错误 | +| `BACKEND_TIMEOUT` | 504 | `internal_error` | 后端模型服务响应超时 | +| `BACKEND_UNAVAILABLE` | 502 | `internal_error` | 后端模型服务不可用 | +| `CONFIG_LOAD_ERROR` | 500 | `internal_error` | 配置加载失败 | +| `USER_QUOTA_EXHAUSTED` | 429 | `quota_error` | 用户级别配额耗尽 | +| `SESSION_QUOTA_EXHAUSTED` | 429 | `quota_error` | 会话级别配额耗尽 | +| `FUNCTION_QUOTA_EXHAUSTED` | 429 | `quota_error` | 功能级别配额耗尽 | +| `COST_BUDGET_EXHAUSTED` | 429 | `quota_error` | 成本预算耗尽 | +| `GEO_RESTRICTED` | 403 | `authentication_error` | 地理区域限制 | +| `TIME_WINDOW_RESTRICTED` | 403 | `authentication_error` | 时间窗口限制 | + +## 5. 错误码与访问日志字段的关联 + +BFE AI 访问日志通过 `mod_access_pb3` 输出以下与错误相关的字段,便于排查和计费对账: + +| 日志字段 | 说明 | +|----------|------| +| `ai_auth_reject_reason` | 认证/配额拒绝时的错误码,对应本文档中的 `code` 字段 | +| `ai_auth_reject_quota_plans` | 因配额不足被拒绝时,余量不足的 Quota Plan ID 列表 | +| `ai_auth_hit_quota_plans` | 认证通过且余额充足的 Quota Plan ID 列表 | +| `ai_rate_limit_hits` | 触发的限流策略及规则名列表 | + +更多访问日志字段说明请参考 [BFE AI 访问日志可观测字段设计](./ai_access_log_fields.md)。 + +## 6. 排查建议 + +| 状态码 | 常见原因 | 建议 | +|--------|----------|------| +| 400 | 请求参数或模型不合法、协议不匹配 | 检查请求体、模型名称、协议风格与目标集群配置 | +| 401 | 缺少 API Key 或 Key 不存在 | 检查 `Authorization` / `x-api-key` 头及 Key 配置 | +| 403 | Key 被禁用/过期、IP 不在白名单 | 检查 Token 状态、过期时间及子网配置 | +| 429 | 配额不足或触发限流 | 检查 Quota Plan 余额、RPM/TPM/并发策略阈值 | +| 500 | 内部错误或 Redis 访问失败 | 查看 BFE 错误日志及 Redis 连通性 | +| 502/504 | 后端不可用或超时 | 检查后端模型服务健康状态及网络 | + +## 7. 参考 + +- `bfe_basic/request_ai_basic.go`:错误码与错误响应结构的 Go 语言定义 +- `bfe_modules/mod_ai_token_auth/token_rule_table.go`:认证与配额错误产生逻辑 +- `bfe_modules/mod_ai_rate_limit/mod_ai_rate_limit.go`:限流错误产生逻辑 +- `bfe_server/reverseproxy.go`:协议适配错误产生逻辑 +- [BFE AI 访问日志可观测字段设计](./ai_access_log_fields.md) From 4060501c856bfd4218f9001f6c39a1397e09a6bf Mon Sep 17 00:00:00 2001 From: zhangmiao Date: Sat, 29 Aug 2026 22:55:09 +0800 Subject: [PATCH 18/18] chore: bump version to 1.8.6 and update CHANGELOG Signed-off-by: zhangmiao --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ VERSION | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6795dbd0..79dba14da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v1.8.6] - 2026-08-29 + +### Added +- Support Anthropic Claude protocol forwarding with model protocol matching ([Pull #1331](https://github.com/bfenetworks/bfe/pull/1331)) +- Support RMB tiered pricing with peak/off-peak tiers ([Pull #1335](https://github.com/bfenetworks/bfe/pull/1335)) +- Support cache token billing for AI requests ([Pull #1330](https://github.com/bfenetworks/bfe/pull/1330)) +- Support audio token RMB billing ([Pull #1331](https://github.com/bfenetworks/bfe/pull/1331)) +- Support image generation per-image billing ([Pull #1331](https://github.com/bfenetworks/bfe/pull/1331)) +- Recognize DeepSeek cache fields in usage parsing ([Pull #1337](https://github.com/bfenetworks/bfe/pull/1337)) +- Add session-level API-Key affinity within the same cluster ([Pull #1337](https://github.com/bfenetworks/bfe/pull/1337)) +- Add `req_body_larger_than` and `req_body_less_than` condition primitives ([Pull #1336](https://github.com/bfenetworks/bfe/pull/1336)) +- Add `GetInt64Batch` to redis_client for batch quota remaining reads ([Pull #1337](https://github.com/bfenetworks/bfe/pull/1337)) +- Use control-plane generated redis_key for AI rate limit counters ([Pull #1331](https://github.com/bfenetworks/bfe/pull/1331)) +- Support full Redis key cleanup exported by rate-limit export ([Pull #1333](https://github.com/bfenetworks/bfe/pull/1333)) +- Add BFE AI gateway error codes documentation ([Pull #1339](https://github.com/bfenetworks/bfe/pull/1339)) + +### Changed +- Change session-level API-Key affinity TTL to idle timeout with default 600s ([Pull #1337](https://github.com/bfenetworks/bfe/pull/1337)) +- Simplify token_rule.data fields and add TagLevel in mod_ai_token_auth ([Pull #1329](https://github.com/bfenetworks/bfe/pull/1329)) + +### Fixed +- Fix model modification when retry between clusters in ServeHTTPForAI ([Pull #1334](https://github.com/bfenetworks/bfe/pull/1334)) +- Fix PriceMap/TierPriceMap JSON serialization to preserve 8-decimal precision ([Pull #1338](https://github.com/bfenetworks/bfe/pull/1338)) + + ## [v1.8.5] - 2026-08-21 ### Added @@ -465,6 +490,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Flexible plugin framework to extend functionality. Based on the framework, developer can add new features rapidly - Detailed built-in metrics available for service status monitor +[v1.8.6]: https://github.com/bfenetworks/bfe/compare/v1.8.5...v1.8.6 [v1.8.5]: https://github.com/bfenetworks/bfe/compare/v1.8.4...v1.8.5 [v1.8.4]: https://github.com/bfenetworks/bfe/compare/v1.8.3...v1.8.4 [v1.6.0]: https://github.com/bfenetworks/bfe/compare/v1.5.0...v1.6.0 diff --git a/VERSION b/VERSION index 8decb929b..f263cd11b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.5 +1.8.6