mirror of
https://github.com/alibaba/higress.git
synced 2026-06-24 09:45:16 +08:00
feat(model-router): add keepOriginalModelName option to preserve full model name (#3916)
Signed-off-by: Cai Rui <yangjuan.cr@alibaba-inc.com>
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
| `addProviderHeader` | string | 选填 | - | 从model参数中解析出的provider名字放到哪个请求header中 |
|
| `addProviderHeader` | string | 选填 | - | 从model参数中解析出的provider名字放到哪个请求header中 |
|
||||||
| `modelToHeader` | string | 选填 | - | 直接将model参数放到哪个请求header中 |
|
| `modelToHeader` | string | 选填 | - | 直接将model参数放到哪个请求header中 |
|
||||||
| `enableOnPathSuffix` | array of string | 选填 | ["/completions","/embeddings","/images/generations","/audio/speech","/fine_tuning/jobs","/moderations","/image-synthesis","/video-synthesis","/rerank","/messages"] | 只对这些特定路径后缀的请求生效,可以配置为 "*" 以匹配所有路径 |
|
| `enableOnPathSuffix` | array of string | 选填 | ["/completions","/embeddings","/images/generations","/audio/speech","/fine_tuning/jobs","/moderations","/image-synthesis","/video-synthesis","/rerank","/messages"] | 只对这些特定路径后缀的请求生效,可以配置为 "*" 以匹配所有路径 |
|
||||||
|
| `keepOriginalModelName` | bool | 选填 | false | 配合 `addProviderHeader` 使用,设为 true 时仍提取 provider 写入 header,但不改写请求体中的 model 字段 |
|
||||||
| `autoRouting` | object | 选填 | - | 自动路由配置,详见下方说明 |
|
| `autoRouting` | object | 选填 | - | 自动路由配置,详见下方说明 |
|
||||||
|
|
||||||
### autoRouting 配置
|
### autoRouting 配置
|
||||||
@@ -113,6 +114,21 @@ x-higress-llm-provider: dashscope
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 保留原始模型名(keepOriginalModelName)
|
||||||
|
|
||||||
|
当使用 AI 模型聚合平台(如百炼/DashScope)接入第三方厂商模型时,部分模型名称本身包含 `/`(如 `MiniMax/MiniMax-M2.7`),并非 `provider/model` 格式。此时配合 `addProviderHeader` 使用会导致请求体中的 model 字段被错误改写。
|
||||||
|
|
||||||
|
通过设置 `keepOriginalModelName: true`,可以在保留 provider header 提取能力的同时,不改写请求体中的 model 字段:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
addProviderHeader: x-higress-llm-provider
|
||||||
|
keepOriginalModelName: true
|
||||||
|
```
|
||||||
|
|
||||||
|
以 model 为 `MiniMax/MiniMax-M2.7` 为例,经过插件后:
|
||||||
|
- 请求头 `x-higress-llm-provider` 设置为 `MiniMax`
|
||||||
|
- 请求体中的 model 字段保持为 `MiniMax/MiniMax-M2.7`(不改写)
|
||||||
|
|
||||||
### 自动路由模式(基于用户消息内容)
|
### 自动路由模式(基于用户消息内容)
|
||||||
|
|
||||||
当请求中的 model 参数设置为 `higress/auto` 时,插件会自动分析用户消息内容,并根据配置的正则规则选择合适的模型进行路由。
|
当请求中的 model 参数设置为 `higress/auto` 时,插件会自动分析用户消息内容,并根据配置的正则规则选择合适的模型进行路由。
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ The `model-router` plugin implements routing functionality based on the model pa
|
|||||||
| `addProviderHeader` | string | Optional | - | Which request header to add the provider name parsed from the model parameter |
|
| `addProviderHeader` | string | Optional | - | Which request header to add the provider name parsed from the model parameter |
|
||||||
| `modelToHeader` | string | Optional | - | Which request header to directly add the model parameter to |
|
| `modelToHeader` | string | Optional | - | Which request header to directly add the model parameter to |
|
||||||
| `enableOnPathSuffix` | array of string | Optional | ["/completions","/embeddings","/images/generations","/audio/speech","/fine_tuning/jobs","/moderations","/image-synthesis","/video-synthesis","/rerank","/messages"] | Only effective for requests with these specific path suffixes, can be configured as "*" to match all paths |
|
| `enableOnPathSuffix` | array of string | Optional | ["/completions","/embeddings","/images/generations","/audio/speech","/fine_tuning/jobs","/moderations","/image-synthesis","/video-synthesis","/rerank","/messages"] | Only effective for requests with these specific path suffixes, can be configured as "*" to match all paths |
|
||||||
|
| `keepOriginalModelName` | bool | Optional | false | Used with `addProviderHeader`. When set to true, the provider is still extracted into the header, but the model field in the request body is not rewritten |
|
||||||
|
|
||||||
## Runtime Properties
|
## Runtime Properties
|
||||||
|
|
||||||
@@ -95,3 +96,19 @@ The original LLM request body will be changed to:
|
|||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"top_p": 0.95
|
"top_p": 0.95
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preserving Original Model Name (keepOriginalModelName)
|
||||||
|
|
||||||
|
When using AI model aggregation platforms (e.g. Bailian/DashScope) with third-party models, some model names inherently contain `/` (e.g. `MiniMax/MiniMax-M2.7`) and are not in `provider/model` format. In this case, using `addProviderHeader` would incorrectly rewrite the model field in the request body.
|
||||||
|
|
||||||
|
By setting `keepOriginalModelName: true`, you can keep the provider header extraction while preserving the original model field in the request body:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
addProviderHeader: x-higress-llm-provider
|
||||||
|
keepOriginalModelName: true
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, with model `MiniMax/MiniMax-M2.7`, after processing by the plugin:
|
||||||
|
- Request header `x-higress-llm-provider` is set to `MiniMax`
|
||||||
|
- The model field in the request body remains `MiniMax/MiniMax-M2.7` (not rewritten)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type ModelRouterConfig struct {
|
|||||||
addProviderHeader string
|
addProviderHeader string
|
||||||
modelToHeader string
|
modelToHeader string
|
||||||
enableOnPathSuffix []string
|
enableOnPathSuffix []string
|
||||||
|
keepOriginalModelName bool
|
||||||
// Auto routing configuration
|
// Auto routing configuration
|
||||||
enableAutoRouting bool
|
enableAutoRouting bool
|
||||||
autoRoutingRules []AutoRoutingRule
|
autoRoutingRules []AutoRoutingRule
|
||||||
@@ -60,6 +61,7 @@ func parseConfig(json gjson.Result, config *ModelRouterConfig) error {
|
|||||||
}
|
}
|
||||||
config.addProviderHeader = json.Get("addProviderHeader").String()
|
config.addProviderHeader = json.Get("addProviderHeader").String()
|
||||||
config.modelToHeader = json.Get("modelToHeader").String()
|
config.modelToHeader = json.Get("modelToHeader").String()
|
||||||
|
config.keepOriginalModelName = json.Get("keepOriginalModelName").Bool()
|
||||||
|
|
||||||
enableOnPathSuffix := json.Get("enableOnPathSuffix")
|
enableOnPathSuffix := json.Get("enableOnPathSuffix")
|
||||||
if enableOnPathSuffix.Exists() && enableOnPathSuffix.IsArray() {
|
if enableOnPathSuffix.Exists() && enableOnPathSuffix.IsArray() {
|
||||||
@@ -253,12 +255,14 @@ func handleJsonBody(ctx wrapper.HttpContext, config ModelRouterConfig, body []by
|
|||||||
model := parts[1]
|
model := parts[1]
|
||||||
_ = proxywasm.ReplaceHttpRequestHeader(config.addProviderHeader, provider)
|
_ = proxywasm.ReplaceHttpRequestHeader(config.addProviderHeader, provider)
|
||||||
|
|
||||||
|
if !config.keepOriginalModelName {
|
||||||
newBody, err := sjson.SetBytes(body, config.modelKey, model)
|
newBody, err := sjson.SetBytes(body, config.modelKey, model)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("failed to update model in json body: %v", err)
|
log.Errorf("failed to update model in json body: %v", err)
|
||||||
return types.ActionContinue
|
return types.ActionContinue
|
||||||
}
|
}
|
||||||
_ = proxywasm.ReplaceHttpRequestBody(newBody)
|
_ = proxywasm.ReplaceHttpRequestBody(newBody)
|
||||||
|
}
|
||||||
log.Debugf("model route to provider: %s, model: %s", provider, model)
|
log.Debugf("model route to provider: %s, model: %s", provider, model)
|
||||||
} else {
|
} else {
|
||||||
log.Debugf("model route to provider not work, model: %s", modelValue)
|
log.Debugf("model route to provider not work, model: %s", modelValue)
|
||||||
@@ -319,6 +323,7 @@ func handleMultipartBody(ctx wrapper.HttpContext, config ModelRouterConfig, body
|
|||||||
model := parts[1]
|
model := parts[1]
|
||||||
_ = proxywasm.ReplaceHttpRequestHeader(config.addProviderHeader, provider)
|
_ = proxywasm.ReplaceHttpRequestHeader(config.addProviderHeader, provider)
|
||||||
|
|
||||||
|
if !config.keepOriginalModelName {
|
||||||
// Write modified part
|
// Write modified part
|
||||||
h := make(http.Header)
|
h := make(http.Header)
|
||||||
for k, v := range part.Header {
|
for k, v := range part.Header {
|
||||||
@@ -338,6 +343,8 @@ func handleMultipartBody(ctx wrapper.HttpContext, config ModelRouterConfig, body
|
|||||||
modified = true
|
modified = true
|
||||||
log.Debugf("model route to provider: %s, model: %s", provider, model)
|
log.Debugf("model route to provider: %s, model: %s", provider, model)
|
||||||
continue
|
continue
|
||||||
|
}
|
||||||
|
log.Debugf("model route to provider: %s, model kept: %s", provider, modelValue)
|
||||||
} else {
|
} else {
|
||||||
log.Debugf("model route to provider not work, model: %s", modelValue)
|
log.Debugf("model route to provider not work, model: %s", modelValue)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,6 +288,126 @@ func TestOnHttpRequestBody_Multipart(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var keepOriginalModelConfig = func() json.RawMessage {
|
||||||
|
data, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"modelKey": "model",
|
||||||
|
"addProviderHeader": "x-provider",
|
||||||
|
"modelToHeader": "x-model",
|
||||||
|
"keepOriginalModelName": true,
|
||||||
|
"enableOnPathSuffix": []string{
|
||||||
|
"/v1/chat/completions",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}()
|
||||||
|
|
||||||
|
func TestParseConfigKeepOriginalModelName(t *testing.T) {
|
||||||
|
test.RunGoTest(t, func(t *testing.T) {
|
||||||
|
t.Run("default false", func(t *testing.T) {
|
||||||
|
var cfg ModelRouterConfig
|
||||||
|
err := parseConfig(gjson.ParseBytes(basicConfig), &cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, cfg.keepOriginalModelName)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("parse true", func(t *testing.T) {
|
||||||
|
var cfg ModelRouterConfig
|
||||||
|
err := parseConfig(gjson.ParseBytes(keepOriginalModelConfig), &cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, cfg.keepOriginalModelName)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeepOriginalModelName(t *testing.T) {
|
||||||
|
test.RunTest(t, func(t *testing.T) {
|
||||||
|
t.Run("json: provider header set but body model preserved", func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(keepOriginalModelConfig)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/v1/chat/completions"},
|
||||||
|
{":method", "POST"},
|
||||||
|
{"content-type", "application/json"},
|
||||||
|
})
|
||||||
|
|
||||||
|
origBody := []byte(`{
|
||||||
|
"model": "MiniMax/MiniMax-M2.7",
|
||||||
|
"messages": [{"role": "user", "content": "hello"}]
|
||||||
|
}`)
|
||||||
|
action := host.CallOnHttpRequestBody(origBody)
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
|
||||||
|
headers := host.GetRequestHeaders()
|
||||||
|
// model header keeps the full name
|
||||||
|
hv, found := getHeader(headers, "x-model")
|
||||||
|
require.True(t, found)
|
||||||
|
require.Equal(t, "MiniMax/MiniMax-M2.7", hv)
|
||||||
|
// provider header IS set (split still extracts provider)
|
||||||
|
pv, found := getHeader(headers, "x-provider")
|
||||||
|
require.True(t, found)
|
||||||
|
require.Equal(t, "MiniMax", pv)
|
||||||
|
|
||||||
|
// body model must remain intact (not rewritten)
|
||||||
|
processed := host.GetRequestBody()
|
||||||
|
require.NotNil(t, processed)
|
||||||
|
require.Equal(t, "MiniMax/MiniMax-M2.7", gjson.GetBytes(processed, "model").String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multipart: provider header set but body model preserved", func(t *testing.T) {
|
||||||
|
host, status := test.NewTestHost(keepOriginalModelConfig)
|
||||||
|
defer host.Reset()
|
||||||
|
require.Equal(t, types.OnPluginStartStatusOK, status)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&buf)
|
||||||
|
modelWriter, err := writer.CreateFormField("model")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = modelWriter.Write([]byte("MiniMax/MiniMax-M2.7"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = writer.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
contentType := "multipart/form-data; boundary=" + writer.Boundary()
|
||||||
|
host.CallOnHttpRequestHeaders([][2]string{
|
||||||
|
{":authority", "example.com"},
|
||||||
|
{":path", "/v1/chat/completions"},
|
||||||
|
{":method", "POST"},
|
||||||
|
{"content-type", contentType},
|
||||||
|
})
|
||||||
|
|
||||||
|
action := host.CallOnHttpRequestBody(buf.Bytes())
|
||||||
|
require.Equal(t, types.ActionContinue, action)
|
||||||
|
|
||||||
|
headers := host.GetRequestHeaders()
|
||||||
|
hv, found := getHeader(headers, "x-model")
|
||||||
|
require.True(t, found)
|
||||||
|
require.Equal(t, "MiniMax/MiniMax-M2.7", hv)
|
||||||
|
// provider header IS set
|
||||||
|
pv, found := getHeader(headers, "x-provider")
|
||||||
|
require.True(t, found)
|
||||||
|
require.Equal(t, "MiniMax", pv)
|
||||||
|
|
||||||
|
// body model should not be rewritten
|
||||||
|
processed := host.GetRequestBody()
|
||||||
|
require.NotNil(t, processed)
|
||||||
|
reader := multipart.NewReader(bytes.NewReader(processed), writer.Boundary())
|
||||||
|
for {
|
||||||
|
part, err := reader.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if part.FormName() == "model" {
|
||||||
|
data, _ := io.ReadAll(part)
|
||||||
|
require.Equal(t, "MiniMax/MiniMax-M2.7", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Auto routing config for tests
|
// Auto routing config for tests
|
||||||
var autoRoutingConfig = func() json.RawMessage {
|
var autoRoutingConfig = func() json.RawMessage {
|
||||||
data, _ := json.Marshal(map[string]interface{}{
|
data, _ := json.Marshal(map[string]interface{}{
|
||||||
|
|||||||
Reference in New Issue
Block a user