Commit 32c26192 by CaIon

fix(task): explain 503 when a plugin-claimed model has no channel

A model declared by a task plugin is served only by that plugin's channels.
When the claiming plugin has no enabled channel in the request group, the
distributor answered with the generic "no available channel" text, which hides
the actual cause and led operators to expect channel model_mapping on another
plugin's channel to take over (#7185). That expectation is not supported:
plugin declarations own model names statically, and channel availability must
not silently reassign ownership at request time. The supported fixes are the
existing operator tools, disabling the factory plugin per key or overriding it.

Both no-channel 503 sites in the distributor now route through
noAvailableChannelMessage. When the request is pinned to a task plugin, the
message names the claiming plugin and points to disabling or overriding it;
non-plugin requests keep the generic message. Added in en, zh-CN, zh-TW.
parent 8f5ab8e4
......@@ -311,19 +311,20 @@ const (
// Distributor related messages
const (
MsgDistributorInvalidRequest = "distributor.invalid_request"
MsgDistributorInvalidChannelId = "distributor.invalid_channel_id"
MsgDistributorChannelDisabled = "distributor.channel_disabled"
MsgDistributorAffinityChannelDisabled = "distributor.affinity_channel_disabled"
MsgDistributorTokenNoModelAccess = "distributor.token_no_model_access"
MsgDistributorTokenModelForbidden = "distributor.token_model_forbidden"
MsgDistributorModelNameRequired = "distributor.model_name_required"
MsgDistributorInvalidPlayground = "distributor.invalid_playground_request"
MsgDistributorGroupAccessDenied = "distributor.group_access_denied"
MsgDistributorGetChannelFailed = "distributor.get_channel_failed"
MsgDistributorNoAvailableChannel = "distributor.no_available_channel"
MsgDistributorInvalidMidjourney = "distributor.invalid_midjourney_request"
MsgDistributorInvalidParseModel = "distributor.invalid_request_parse_model"
MsgDistributorInvalidRequest = "distributor.invalid_request"
MsgDistributorInvalidChannelId = "distributor.invalid_channel_id"
MsgDistributorChannelDisabled = "distributor.channel_disabled"
MsgDistributorAffinityChannelDisabled = "distributor.affinity_channel_disabled"
MsgDistributorTokenNoModelAccess = "distributor.token_no_model_access"
MsgDistributorTokenModelForbidden = "distributor.token_model_forbidden"
MsgDistributorModelNameRequired = "distributor.model_name_required"
MsgDistributorInvalidPlayground = "distributor.invalid_playground_request"
MsgDistributorGroupAccessDenied = "distributor.group_access_denied"
MsgDistributorGetChannelFailed = "distributor.get_channel_failed"
MsgDistributorNoAvailableChannel = "distributor.no_available_channel"
MsgDistributorNoAvailableChannelTaskPlugin = "distributor.no_available_channel_task_plugin"
MsgDistributorInvalidMidjourney = "distributor.invalid_midjourney_request"
MsgDistributorInvalidParseModel = "distributor.invalid_request_parse_model"
)
// Custom OAuth provider related messages
......
......@@ -272,6 +272,7 @@ distributor.invalid_playground_request: "Invalid playground request: {{.Error}}"
distributor.group_access_denied: "No permission to access this group"
distributor.get_channel_failed: "Failed to get available channel for model {{.Model}} under group {{.Group}} (distributor): {{.Error}}"
distributor.no_available_channel: "No available channel for model {{.Model}} under group {{.Group}} (distributor)"
distributor.no_available_channel_task_plugin: "No available channel for model {{.Model}} under group {{.Group}}: the model is claimed by task plugin \"{{.Plugin}}\", which has no enabled channel serving it. To let another plugin or channel serve this model, disable or override plugin \"{{.Plugin}}\" in the task plugin console (distributor)"
distributor.invalid_midjourney_request: "Invalid Midjourney request: {{.Error}}"
distributor.invalid_request_parse_model: "Invalid request, unable to parse model"
......
......@@ -273,6 +273,7 @@ distributor.invalid_playground_request: "无效的playground请求,{{.Error}}"
distributor.group_access_denied: "无权访问该分组"
distributor.get_channel_failed: "获取分组 {{.Group}} 下模型 {{.Model}} 的可用渠道失败(distributor):{{.Error}}"
distributor.no_available_channel: "分组 {{.Group}} 下模型 {{.Model}} 无可用渠道(distributor)"
distributor.no_available_channel_task_plugin: "分组 {{.Group}} 下模型 {{.Model}} 无可用渠道:该模型由任务插件「{{.Plugin}}」认领,但该插件当前没有启用的渠道可服务此模型。若要让其他插件或渠道服务该模型,请在任务插件管理中禁用或覆盖插件「{{.Plugin}}」(distributor)"
distributor.invalid_midjourney_request: "无效的midjourney请求,{{.Error}}"
distributor.invalid_request_parse_model: "无效的请求,无法解析模型"
......
......@@ -273,6 +273,7 @@ distributor.invalid_playground_request: "無效的playground請求,{{.Error}}"
distributor.group_access_denied: "無權存取該分組"
distributor.get_channel_failed: "獲取分組 {{.Group}} 下模型 {{.Model}} 的可用管道失敗(distributor):{{.Error}}"
distributor.no_available_channel: "分組 {{.Group}} 下模型 {{.Model}} 無可用管道(distributor)"
distributor.no_available_channel_task_plugin: "分組 {{.Group}} 下模型 {{.Model}} 無可用管道:該模型由任務插件「{{.Plugin}}」認領,但該插件目前沒有啟用的管道可服務此模型。若要讓其他插件或管道服務該模型,請在任務插件管理中停用或覆蓋插件「{{.Plugin}}」(distributor)"
distributor.invalid_midjourney_request: "無效的midjourney請求,{{.Error}}"
distributor.invalid_request_parse_model: "無效的請求,無法解析模型"
......
......@@ -180,7 +180,7 @@ func Distribute() func(c *gin.Context) {
return
}
if channel == nil {
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": usingGroup, "Model": modelRequest.Model}), types.ErrorCodeModelNotFound)
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, noAvailableChannelMessage(c, usingGroup, modelRequest.Model), types.ErrorCodeModelNotFound)
return
}
}
......@@ -191,7 +191,7 @@ func Distribute() func(c *gin.Context) {
if kind == taskdto.FilterTaskPluginIdentity {
logTaskPluginChannelDecision(c, channel, modelRequest.Model, "channel_rejected", "identity_mismatch")
}
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": common.GetContextKeyString(c, constant.ContextKeyUsingGroup), "Model": modelRequest.Model}), types.ErrorCodeModelNotFound)
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, noAvailableChannelMessage(c, common.GetContextKeyString(c, constant.ContextKeyUsingGroup), modelRequest.Model), types.ErrorCodeModelNotFound)
return
}
}
......@@ -204,6 +204,21 @@ func Distribute() func(c *gin.Context) {
}
}
// noAvailableChannelMessage explains a 503 for a task-plugin-claimed model.
// A model claimed by a plugin is served only by that plugin's channels, so the
// generic "no channel" text hides the real cause: the claiming plugin has no
// enabled channel, and the operator must disable or override that plugin for
// any other plugin or channel to take the model. Non-plugin requests keep the
// generic message.
func noAvailableChannelMessage(c *gin.Context, group, modelName string) string {
value, exists := c.Get(jsplugin.ContextKeyPinnedPlugin)
pinned, ok := value.(jsplugin.PinnedPlugin)
if exists && ok && pinned.Plugin != nil {
return i18n.T(c, i18n.MsgDistributorNoAvailableChannelTaskPlugin, map[string]any{"Group": group, "Model": modelName, "Plugin": pinned.Plugin.Meta.Key})
}
return i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": group, "Model": modelName})
}
func channelMatchesExpectedTaskPlugin(c *gin.Context, channel *model.Channel, expected string) bool {
if channel == nil {
return false
......
......@@ -2,9 +2,12 @@ package middleware
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relaykit/dto"
......@@ -147,3 +150,26 @@ export const protocols = {openai_responses: {
}};
`, key, key, channelType)
}
func TestNoAvailableChannelMessageNamesClaimingTaskPlugin(t *testing.T) {
require.NoError(t, i18n.Init())
registry := jsplugin.NewRegistry()
plugin, err := registry.Register(distributorTaskPluginSource("claimer", constant.ChannelTypeKling), jsplugin.Options{})
require.NoError(t, err)
pinned, _ := gin.CreateTestContext(nil)
pinned.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
pinned.Request.Header.Set("Accept-Language", "en")
pinned.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{Generation: registry.Generation(), Plugin: plugin})
message := noAvailableChannelMessage(pinned, "default", "kling-v1")
assert.Contains(t, message, `"claimer"`)
assert.Contains(t, message, "disable or override")
assert.Contains(t, message, "kling-v1")
plain, _ := gin.CreateTestContext(nil)
plain.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
plain.Request.Header.Set("Accept-Language", "en")
generic := noAvailableChannelMessage(plain, "default", "gpt-4o")
assert.NotContains(t, generic, "task plugin")
assert.Contains(t, generic, "gpt-4o")
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment