feat(llm): route MCP agent via aiproxy virtual key and routing id (#25667)

Support X-Ai-Routing-Id to pin ai_routing, treat empty model_pattern as non-wildcard, and wire MCP agent to aiproxy credentials instead of direct LLM api_key.
This commit is contained in:
Zexi Li
2026-09-11 10:14:08 +08:00
committed by GitHub
parent 1074f3694d
commit 3fd882452b
13 changed files with 245 additions and 195 deletions

View File

@@ -36,6 +36,7 @@ import (
)
const headerAiVirtualKey = "X-Ai-Virtual-Key"
const headerAiRoutingId = "X-Ai-Routing-Id"
func extractVirtualKey(r *http.Request) string {
if v := strings.TrimSpace(r.Header.Get(headerAiVirtualKey)); v != "" {
@@ -49,6 +50,10 @@ func extractVirtualKey(r *http.Request) string {
return ""
}
func extractRoutingId(r *http.Request) string {
return strings.TrimSpace(r.Header.Get(headerAiRoutingId))
}
func upstreamErrorStatusCode(uerr *upstream.Error) int {
if uerr == nil || uerr.StatusCode <= 0 {
return 0
@@ -141,7 +146,7 @@ func chatCompletionsHandler(ctx context.Context, w http.ResponseWriter, r *http.
vk := extractVirtualKey(r)
userCred := auth.AdminCredential()
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
if err != nil {
dbg.Error("resolve upstream: %v", err)
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)

View File

@@ -71,7 +71,7 @@ func completionsHandler(ctx context.Context, w http.ResponseWriter, r *http.Requ
vk := extractVirtualKey(r)
userCred := auth.AdminCredential()
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
if err != nil {
dbg.Error("resolve upstream: %v", err)
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)

View File

@@ -69,7 +69,7 @@ func embeddingsHandler(ctx context.Context, w http.ResponseWriter, r *http.Reque
vk := extractVirtualKey(r)
userCred := auth.AdminCredential()
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
if err != nil {
dbg.Error("resolve upstream: %v", err)
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)

View File

@@ -69,7 +69,7 @@ func imagesGenerationsHandler(ctx context.Context, w http.ResponseWriter, r *htt
vk := extractVirtualKey(r)
userCred := auth.AdminCredential()
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
if err != nil {
dbg.Error("resolve upstream: %v", err)
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)

View File

@@ -77,7 +77,7 @@ func messagesHandler(ctx context.Context, w http.ResponseWriter, r *http.Request
vk := extractVirtualKey(r)
userCred := auth.AdminCredential()
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
if err != nil {
dbg.Error("resolve upstream: %v", err)
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)

View File

@@ -97,7 +97,7 @@ func handleResponsesCreate(ctx context.Context, w http.ResponseWriter, r *http.R
vk := extractVirtualKey(r)
userCred := auth.AdminCredential()
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
if err != nil {
dbg.Error("resolve upstream: %v", err)
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)
@@ -293,7 +293,7 @@ func handleResponsesSubResource(ctx context.Context, w http.ResponseWriter, r *h
writeResponsesError(ctx, w, http.StatusBadRequest, "invalid_request_error", "model query parameter is required")
return
}
up, err := models.ResolveChatUpstream(ctx, userCred, vk, probe)
up, err := models.ResolveChatUpstream(ctx, userCred, vk, probe, extractRoutingId(r))
if err != nil {
dbg.Error("resolve upstream: %v", err)
httperrors.GeneralServerError(ctx, w, err)

View File

@@ -191,7 +191,8 @@ func listProjectRoutingsForVirtualKey(ctx context.Context, userCred mcclient.Tok
// pickRoutingForRequest chooses the best matching ai_routing on the current aiproxy instance.
// Hierarchical refs (routingKey/catalogPart) match only ai_routing.model_key on routeKey.
// Flat refs: Phase 1 exact ai_routing.model_key match, Phase 2 ai_routing.model_pattern match.
// Flat refs: Phase 1 exact ai_routing.model_key match, Phase 2 non-empty ai_routing.model_pattern match.
// Empty model_pattern is not a wildcard; unmatched models return nil (ResolveChatUpstream maps that to ErrNotFound).
func pickRoutingForRequest(routings []SAiRouting, reqModel, currentNodeId string) (*SAiRouting, error) {
ref := parseClientModelRef(reqModel)
if ref.hierarchical {
@@ -205,6 +206,9 @@ func pickRoutingForRequest(routings []SAiRouting, reqModel, currentNodeId string
return picked, err
}
return pickRoutingByMatch(routings, reqModel, currentNodeId, func(r *SAiRouting, reqModel string) bool {
if strings.TrimSpace(r.ModelPattern) == "" {
return false
}
return modelPatternMatches(r.ModelPattern, reqModel)
})
}
@@ -238,6 +242,33 @@ func pickRoutingByMatch(routings []SAiRouting, reqModel, currentNodeId string, m
return nil, nil
}
func pickRoutingById(routings []SAiRouting, routingId, currentNodeId string) (*SAiRouting, error) {
routingId = strings.TrimSpace(routingId)
if routingId == "" {
return nil, nil
}
var boundElsewhere *SAiRouting
for i := range routings {
r := &routings[i]
if r.Id != routingId {
continue
}
if !proxyNodeScopeMatches(r.AiProxyNodeId, currentNodeId) {
if strings.TrimSpace(r.AiProxyNodeId) != "" {
boundElsewhere = r
}
continue
}
return r, nil
}
if boundElsewhere != nil {
return nil, errors.Wrapf(httperrors.ErrForbidden,
"ai_routing %q is bound to ai_proxy_node %q; use that instance endpoint",
boundElsewhere.Name, boundElsewhere.AiProxyNodeId)
}
return nil, nil
}
type resolvedCatalogModel struct {
provider *SAiProvider
model *SAiModel
@@ -291,10 +322,10 @@ func resolveCatalogModelFromRouting(
// ResolveChatUpstream resolves upstream URL, API key, and catalog model_key for a chat request:
// 1. ai_virtual_key (auth + project scope)
// 2. ai_routing in that project (model_key exact match first, then model_pattern / optional proxy-node scope, priority)
// 2. optional preferredRoutingId (X-Ai-Routing-Id) pins the ai_routing; otherwise model_key then non-empty model_pattern
// 3. ai_routing_model -> ai_provider + ai_model
// 4. ai_key rows for that provider matching the catalog model_key (weight)
func ResolveChatUpstream(ctx context.Context, userCred mcclient.TokenCredential, virtualKey string, body *jsonutils.JSONDict) (*ChatUpstream, error) {
func ResolveChatUpstream(ctx context.Context, userCred mcclient.TokenCredential, virtualKey string, body *jsonutils.JSONDict, preferredRoutingId string) (*ChatUpstream, error) {
vk, err := loadEnabledVirtualKey(virtualKey)
if err != nil {
return nil, err
@@ -309,7 +340,12 @@ func ResolveChatUpstream(ctx context.Context, userCred mcclient.TokenCredential,
if err != nil {
return nil, err
}
routing, err := pickRoutingForRequest(routings, reqModel, CurrentProxyNodeId())
var routing *SAiRouting
if strings.TrimSpace(preferredRoutingId) != "" {
routing, err = pickRoutingById(routings, preferredRoutingId, CurrentProxyNodeId())
} else {
routing, err = pickRoutingForRequest(routings, reqModel, CurrentProxyNodeId())
}
if err != nil {
return nil, err
}

View File

@@ -103,11 +103,41 @@ func TestRouterRequestModel(t *testing.T) {
}
}
func TestPickRoutingForRequestBoundElsewhereHierarchical(t *testing.T) {
routings := []SAiRouting{
{Priority: 10, ModelKey: "claude", AiProxyNodeId: "node-b"},
func TestPickRoutingByIdHitsPreferred(t *testing.T) {
qwen := SAiRouting{ModelKey: "qwen38-Qwen3.8-27B-NVFP4"}
qwen.Id = "rid-qwen"
qwen.Name = "qwen"
ds := SAiRouting{ModelKey: "deepseek-v4-flash"}
ds.Id = "rid-ds"
ds.Name = "deepseek"
routings := []SAiRouting{qwen, ds}
picked, err := pickRoutingById(routings, "rid-qwen", "primary")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
_, err := pickRoutingForRequest(routings, "claude/gpt-4", "node-a")
if picked == nil || picked.Id != "rid-qwen" {
t.Fatalf("expected pinned qwen routing, got %#v", picked)
}
}
func TestPickRoutingByIdNotInList(t *testing.T) {
ds := SAiRouting{ModelKey: "deepseek-v4-flash"}
ds.Id = "rid-ds"
picked, err := pickRoutingById([]SAiRouting{ds}, "rid-missing", "primary")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if picked != nil {
t.Fatalf("expected nil when routing id is not visible, got %#v", picked)
}
}
func TestPickRoutingByIdBoundElsewhere(t *testing.T) {
r := SAiRouting{ModelKey: "qwen", AiProxyNodeId: "node-b"}
r.Id = "rid-qwen"
r.Name = "qwen"
_, err := pickRoutingById([]SAiRouting{r}, "rid-qwen", "node-a")
if err == nil {
t.Fatal("expected forbidden error")
}

View File

@@ -169,6 +169,20 @@ func TestPickRoutingForRequestFallbackPattern(t *testing.T) {
}
}
func TestPickRoutingForRequestEmptyPatternDoesNotMatch(t *testing.T) {
routings := []SAiRouting{
{Priority: 10, ModelPattern: ""},
{Priority: 20, ModelKey: "deepseek-v4-flash"},
}
picked, err := pickRoutingForRequest(routings, "qwen38-Qwen3.8-27B-NVFP4", "primary")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if picked != nil {
t.Fatalf("empty model_pattern must not catch unmatched models, got %#v", picked)
}
}
func TestModelKeyMatches(t *testing.T) {
if !modelKeyMatches("Foo", "foo") {
t.Fatal("expected case-insensitive match")

View File

@@ -76,33 +76,39 @@ type MCPAgentListInput struct {
type MCPAgentCreateInput struct {
apis.SharableVirtualResourceCreateInput
LLMId string `json:"llm_id" help:"LLM 实例 ID如果提供则自动获取 llm_url"`
LLMUrl string `json:"llm_url" help:"后端大模型的 base 请求地址"`
LLMDriver string `json:"llm_driver" help:"使用的大模型驱动,可以是 ollama 或 openai"`
Model string `json:"model" help:"使用的模型名称"`
ApiKey string `json:"api_key" help:"在 llm_driver 中需要用到的认证"`
McpServer string `json:"mcp_server" help:"mcp 服务器的后端地址"`
DefaultAgent *bool `json:"default_agent,omitempty" help:"set as default MCP agent (only one can be true globally)"`
LLMId string `json:"llm_id" help:"deprecated; ignored"`
LLMUrl string `json:"llm_url" help:"AI 网关 OpenAI 兼容 base 请求地址"`
LLMDriver string `json:"llm_driver" help:"使用的大模型驱动,固定为 openai"`
Model string `json:"model" help:"使用的模型名称"`
ApiKey string `json:"api_key" help:"deprecated; ignored"`
McpServer string `json:"mcp_server" help:"mcp 服务器的后端地址"`
AiProxyRoutingId string `json:"aiproxy_routing_id" help:"关联的 AI 网关路由规则 ID"`
AiproxyVirtualKeyId string `json:"aiproxy_virtual_key_id" help:"关联的 AI 网关 API Key ID"`
DefaultAgent *bool `json:"default_agent,omitempty" help:"set as default MCP agent (only one can be true globally)"`
}
type MCPAgentUpdateInput struct {
apis.SharableVirtualResourceCreateInput
LLMId *string `json:"llm_id,omitempty" help:"LLM 实例 ID如果提供则自动获取 llm_url"`
LLMUrl *string `json:"llm_url,omitempty" help:"后端大模型的 base 请求地址"`
LLMDriver *string `json:"llm_driver,omitempty" help:"使用的大模型驱动,可以是 ollama 或 openai"`
Model *string `json:"model,omitempty" help:"使用的模型名称"`
ApiKey *string `json:"api_key,omitempty" help:"在 llm_driver 中需要用到的认证"`
McpServer *string `json:"mcp_server,omitempty" help:"mcp 服务器的后端地址"`
DefaultAgent *bool `json:"default_agent,omitempty" help:"set as default MCP agent (only one can be true globally)"`
LLMId *string `json:"llm_id,omitempty" help:"deprecated; ignored"`
LLMUrl *string `json:"llm_url,omitempty" help:"AI 网关 OpenAI 兼容 base 请求地址"`
LLMDriver *string `json:"llm_driver,omitempty" help:"使用的大模型驱动,固定为 openai"`
Model *string `json:"model,omitempty" help:"使用的模型名称"`
ApiKey *string `json:"api_key,omitempty" help:"deprecated; ignored"`
McpServer *string `json:"mcp_server,omitempty" help:"mcp 服务器的后端地址"`
AiProxyRoutingId *string `json:"aiproxy_routing_id,omitempty" help:"关联的 AI 网关路由规则 ID"`
AiproxyVirtualKeyId *string `json:"aiproxy_virtual_key_id,omitempty" help:"关联的 AI 网关 API Key ID"`
DefaultAgent *bool `json:"default_agent,omitempty" help:"set as default MCP agent (only one can be true globally)"`
}
type MCPAgentDetails struct {
apis.SharableVirtualResourceDetails
LLMId string `json:"llm_id"`
LLMName string `json:"llm_name"`
DefaultAgent bool `json:"default_agent"`
LLMId string `json:"llm_id"`
LLMName string `json:"llm_name"`
AiProxyRoutingId string `json:"aiproxy_routing_id"`
AiproxyVirtualKeyId string `json:"aiproxy_virtual_key_id"`
DefaultAgent bool `json:"default_agent"`
}
type LLMToolRequestInput struct {

View File

@@ -29,6 +29,20 @@ func newOpenAI() models.ILLMClient {
return new(openai)
}
func setAiproxyRequestHeaders(ctx context.Context, httpReq *http.Request, mcpAgent *models.SMCPAgent) error {
apiKey, err := mcpAgent.GetAiproxyVirtualKey(ctx)
if err != nil {
return err
}
if apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
}
if rid := strings.TrimSpace(mcpAgent.AiproxyRoutingId); rid != "" {
httpReq.Header.Set("X-Ai-Routing-Id", rid)
}
return nil
}
func (o *openai) GetType() api.LLMClientType {
return api.LLM_CLIENT_OPENAI
}
@@ -283,13 +297,9 @@ func (o *openai) doChatStreamRequest(ctx context.Context, mcpAgent *models.SMCPA
return errors.Wrap(err, "create request")
}
httpReq.Header.Set("Content-Type", "application/json")
apiKey, err := mcpAgent.GetApiKey()
if err != nil {
if err := setAiproxyRequestHeaders(ctx, httpReq, mcpAgent); err != nil {
return err
}
if apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
}
client := &http.Client{
// Stream request no timeout
@@ -372,12 +382,8 @@ func (o *openai) doChatRequest(ctx context.Context, mcpAgent *models.SMCPAgent,
return nil, errors.Wrap(err, "create request")
}
httpReq.Header.Set("Content-Type", "application/json")
apiKey, err := mcpAgent.GetApiKey()
if err != nil {
return nil, errors.Wrap(err, "get apiKey")
}
if apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
if err := setAiproxyRequestHeaders(ctx, httpReq, mcpAgent); err != nil {
return nil, err
}
client := &http.Client{

View File

@@ -12,7 +12,6 @@ import (
"yunion.io/x/jsonutils"
"yunion.io/x/log"
"yunion.io/x/pkg/errors"
seclib "yunion.io/x/pkg/utils"
"yunion.io/x/sqlchemy"
api "yunion.io/x/onecloud/pkg/apis/llm"
@@ -23,6 +22,7 @@ import (
"yunion.io/x/onecloud/pkg/llm/options"
"yunion.io/x/onecloud/pkg/llm/utils"
"yunion.io/x/onecloud/pkg/mcclient"
apmodules "yunion.io/x/onecloud/pkg/mcclient/modules/aiproxy"
"yunion.io/x/onecloud/pkg/util/stringutils2"
)
@@ -119,17 +119,21 @@ func (man *SMCPAgentManager) GetDefaultMcpServerTools(ctx context.Context, userC
type SMCPAgent struct {
db.SSharableVirtualResourceBase
// LLMId 关联的 LLM 实例 ID
LLMId string `width:"128" charset:"ascii" nullable:"true" list:"user" create:"optional" update:"user"`
// LLMId 旧字段,新建不再写入
LLMId string `width:"128" charset:"ascii" nullable:"true" list:"user"`
// AiproxyRoutingId 关联的 AI 网关路由规则 ID
AiproxyRoutingId string `width:"128" charset:"ascii" nullable:"true" list:"user" create:"required" update:"user" json:"aiproxy_routing_id"`
// AiproxyVirtualKeyId 关联的 AI 网关 API Key ID密钥只存在 aiproxy聊天时按 ID 读取)
AiproxyVirtualKeyId string `width:"128" charset:"ascii" nullable:"true" list:"user" create:"required" update:"user" json:"aiproxy_virtual_key_id"`
// LLMUrl 对应后端大模型的 base 请求地址
// LLMUrl 对应 aiproxy OpenAI 兼容 base 请求地址
LLMUrl string `width:"512" charset:"utf8" nullable:"false" list:"user" create:"required" update:"user"`
// LLMDriver 对应使用的大模型驱动llm_client现在可以被设置为 ollama 或 openai
// LLMDriver 固定为 openai走 AI 网关)
LLMDriver string `width:"64" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
// Model 使用的模型名称
Model string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
// ApiKey 即在 llm_driver 中需要用到的认证
ApiKey string `width:"512" charset:"utf8" nullable:"true" create:"optional" update:"user"`
// Model 使用的模型名称(可为 aiproxy 扁平或层级 client model id
Model string `width:"256" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
// ApiKey 旧字段,新建不再写入
ApiKey string `width:"512" charset:"utf8" nullable:"true"`
// McpServer 即 mcp 服务器的后端地址
McpServer string `width:"512" charset:"utf8" nullable:"false" list:"user" create:"optional" update:"user"`
// DefaultAgent 是否为默认 Agent全局仅允许一条为 true
@@ -140,32 +144,11 @@ func (mcp *SMCPAgent) BeforeInsert() {
if len(mcp.Id) == 0 {
mcp.Id = db.DefaultUUIDGenerator()
}
if len(mcp.ApiKey) > 0 {
sec, err := seclib.EncryptAESBase64(mcp.Id, mcp.ApiKey)
if err != nil {
log.Errorf("EncryptAESBase64 fail %s", err)
} else {
mcp.ApiKey = sec
}
}
mcp.ApiKey = ""
mcp.LLMId = ""
mcp.SSharableVirtualResourceBase.BeforeInsert()
}
func (mcp *SMCPAgent) BeforeUpdate() {
if len(mcp.ApiKey) > 0 {
// heuristic to check if it is plaintext
_, err := seclib.DescryptAESBase64(mcp.Id, mcp.ApiKey)
if err != nil {
sec, err := seclib.EncryptAESBase64(mcp.Id, mcp.ApiKey)
if err != nil {
log.Errorf("EncryptAESBase64 fail %s", err)
} else {
mcp.ApiKey = sec
}
}
}
}
func (mcp *SMCPAgent) PostCreate(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data jsonutils.JSONObject) {
mcp.SSharableVirtualResourceBase.PostCreate(ctx, userCred, ownerId, query, data)
if mcp.DefaultAgent {
@@ -182,18 +165,36 @@ func (mcp *SMCPAgent) PostUpdate(ctx context.Context, userCred mcclient.TokenCre
log.Errorf("unsetOtherDefaultAgents after update: %v", err)
}
}
if strings.TrimSpace(mcp.ApiKey) != "" || strings.TrimSpace(mcp.LLMId) != "" {
if _, err := db.Update(mcp, func() error {
mcp.ApiKey = ""
mcp.LLMId = ""
return nil
}); err != nil {
log.Errorf("clear mcp agent llm_id/api_key: %v", err)
}
}
}
func (mcp *SMCPAgent) GetApiKey() (string, error) {
if len(mcp.ApiKey) == 0 {
return "", nil
func (mcp *SMCPAgent) GetAiproxyVirtualKey(ctx context.Context) (string, error) {
id := strings.TrimSpace(mcp.AiproxyVirtualKeyId)
if id == "" {
return "", errors.Wrap(httperrors.ErrInvalidStatus, "mcp agent has no aiproxy_virtual_key_id; update the agent to bind an AI gateway API key")
}
// try decrypt
key, err := seclib.DescryptAESBase64(mcp.Id, mcp.ApiKey)
if err == nil {
return key, nil
session := aiproxyAdminSession(ctx)
if session == nil {
return "", errors.Wrap(httperrors.ErrInvalidStatus, "aiproxy admin session is nil")
}
return mcp.ApiKey, nil
resp, err := apmodules.AiVirtualKeys.Get(session, id, nil)
if err != nil {
return "", errors.Wrapf(err, "get ai_virtual_key %s", id)
}
key, _ := resp.GetString("virtual_key")
key = strings.TrimSpace(key)
if key == "" {
return "", errors.Wrapf(httperrors.ErrInvalidStatus, "ai_virtual_key %s has empty virtual_key", id)
}
return key, nil
}
func (man *SMCPAgentManager) CustomizeHandlerInfo(info *appsrv.SHandlerInfo) {
@@ -228,53 +229,33 @@ func (man *SMCPAgentManager) ValidateCreateData(ctx context.Context, userCred mc
return input, errors.Wrap(err, "validate SharableVirtualResourceCreateInput")
}
// 如果提供了 llm_id则通过 LLM 获取 llm_url 和 model
if len(input.LLMId) > 0 {
llm, err := FetchAccessibleLLM(ctx, userCred, input.LLMId)
if err != nil {
return input, errors.Wrapf(err, "fetch LLM by id %s", input.LLMId)
}
input.LLMId = llm.Id
llmUrl, err := llm.GetLLMAccessUrlInfo(ctx, userCred, query)
if err != nil {
return input, errors.Wrapf(err, "get LLM URL from LLM %s", input.LLMId)
}
input.LLMUrl = llmUrl.LoginUrl
if len(input.Model) == 0 {
mdlInfos, err := llm.getProbedInstantModelsExt(ctx, userCred)
if err != nil {
return input, errors.Wrap(err, "get probed models from LLM instance")
}
if len(mdlInfos) == 0 {
return input, httperrors.NewBadRequestError("no available models found in LLM instance %s", input.LLMId)
}
var firstModel api.LLMInternalInstantMdlInfo
for _, mdlInfo := range mdlInfos {
firstModel = mdlInfo
break
}
input.Model = fmt.Sprintf("%s:%s", firstModel.Name, firstModel.Tag)
}
input.LLMId = ""
input.ApiKey = ""
input.AiProxyRoutingId = strings.TrimSpace(input.AiProxyRoutingId)
input.AiproxyVirtualKeyId = strings.TrimSpace(input.AiproxyVirtualKeyId)
if input.AiProxyRoutingId == "" {
return input, errors.Wrap(httperrors.ErrInputParameter, "aiproxy_routing_id is required")
}
if input.AiproxyVirtualKeyId == "" {
return input, errors.Wrap(httperrors.ErrInputParameter, "aiproxy_virtual_key_id is required")
}
// 验证 llm_url 不为空
if len(input.LLMUrl) == 0 {
return input, errors.Wrap(httperrors.ErrInputParameter, "llm_url is required (or provide llm_id to auto-fetch)")
return input, errors.Wrap(httperrors.ErrInputParameter, "llm_url is required")
}
// 验证 llm_driver 必须是 ollama 或 openai
input.LLMDriver = strings.ToLower(strings.TrimSpace(input.LLMDriver))
if !api.IsLLMClientType(input.LLMDriver) {
return input, errors.Wrapf(httperrors.ErrInputParameter, "llm_driver must be one of: %s, got: %s", api.LLM_CLIENT_TYPES.List(), input.LLMDriver)
if input.LLMDriver == "" {
input.LLMDriver = string(api.LLM_CLIENT_OPENAI)
}
if input.LLMDriver != string(api.LLM_CLIENT_OPENAI) {
return input, errors.Wrapf(httperrors.ErrInputParameter, "llm_driver must be %s", api.LLM_CLIENT_OPENAI)
}
// 验证 model 不为空
if len(input.Model) == 0 {
return input, errors.Wrap(httperrors.ErrInputParameter, "model is required")
}
// 验证 mcp_server 不为空
if len(input.McpServer) == 0 {
input.McpServer = options.Options.MCPServerURL
}
@@ -282,11 +263,6 @@ func (man *SMCPAgentManager) ValidateCreateData(ctx context.Context, userCred mc
return input, httperrors.NewInputParameterError("%s", err.Error())
}
// 对于 openai 驱动api_key 是必需的
if input.LLMDriver == string(api.LLM_CLIENT_OPENAI) && len(input.ApiKey) == 0 {
return input, errors.Wrap(httperrors.ErrInputParameter, "api_key is required when llm_driver is openai")
}
input.Status = api.STATUS_READY
return input, nil
}
@@ -298,43 +274,32 @@ func (man *SMCPAgentManager) ValidateUpdateData(ctx context.Context, userCred mc
return input, errors.Wrap(err, "validate SharableVirtualResourceCreateInput")
}
// 如果提供了 llm_id则通过 LLM 获取 llm_url 和 model
if input.LLMId != nil && len(*input.LLMId) > 0 {
llm, err := FetchAccessibleLLM(ctx, userCred, *input.LLMId)
if err != nil {
return input, errors.Wrapf(err, "fetch LLM by id %s", *input.LLMId)
}
llmId := llm.Id
input.LLMId = &llmId
llmUrl, err := llm.GetLLMAccessUrlInfo(ctx, userCred, query)
if err != nil {
return input, errors.Wrapf(err, "get LLM URL from LLM %s", *input.LLMId)
}
input.LLMUrl = &llmUrl.LoginUrl
input.LLMId = nil
input.ApiKey = nil
if input.Model == nil || len(*input.Model) == 0 {
mdlInfos, err := llm.getProbedInstantModelsExt(ctx, userCred)
if err != nil {
return input, errors.Wrap(err, "get probed models from LLM instance")
}
if len(mdlInfos) == 0 {
return input, httperrors.NewBadRequestError("no available models found in LLM instance %s", *input.LLMId)
}
var firstModel api.LLMInternalInstantMdlInfo
for _, mdlInfo := range mdlInfos {
firstModel = mdlInfo
break
}
modelStr := fmt.Sprintf("%s:%s", firstModel.Name, firstModel.Tag)
input.Model = &modelStr
if input.AiProxyRoutingId != nil {
trimmed := strings.TrimSpace(*input.AiProxyRoutingId)
if trimmed == "" {
return input, errors.Wrap(httperrors.ErrInputParameter, "aiproxy_routing_id is required")
}
input.AiProxyRoutingId = &trimmed
}
if input.AiproxyVirtualKeyId != nil {
trimmed := strings.TrimSpace(*input.AiproxyVirtualKeyId)
if trimmed == "" {
return input, errors.Wrap(httperrors.ErrInputParameter, "aiproxy_virtual_key_id is required")
}
input.AiproxyVirtualKeyId = &trimmed
}
// 如果更新 llm_driver验证其值
if input.LLMDriver != nil {
*input.LLMDriver = strings.ToLower(strings.TrimSpace(*input.LLMDriver))
if !api.IsLLMClientType(*input.LLMDriver) {
return input, errors.Wrapf(httperrors.ErrInputParameter, "llm_driver must be one of: %s, got: %s", api.LLM_CLIENT_TYPES.List(), *input.LLMDriver)
if *input.LLMDriver == "" {
openai := string(api.LLM_CLIENT_OPENAI)
input.LLMDriver = &openai
}
if *input.LLMDriver != string(api.LLM_CLIENT_OPENAI) {
return input, errors.Wrapf(httperrors.ErrInputParameter, "llm_driver must be %s", api.LLM_CLIENT_OPENAI)
}
}
@@ -385,29 +350,11 @@ func (manager *SMCPAgentManager) FetchCustomizeColumns(
agents := []SMCPAgent{}
jsonutils.Update(&agents, objs)
llmIds := make([]string, 0)
for i := range agents {
if len(agents[i].LLMId) > 0 {
llmIds = append(llmIds, agents[i].LLMId)
}
}
var llmIdNameMap map[string]string
if len(llmIds) > 0 {
var err error
llmIdNameMap, err = db.FetchIdNameMap2(GetLLMManager(), llmIds)
if err != nil {
log.Errorf("FetchIdNameMap2 for LLMs failed: %v", err)
}
}
for i := range rows {
rows[i].SharableVirtualResourceDetails = vrows[i]
if i < len(agents) {
rows[i].LLMId = agents[i].LLMId
if name, ok := llmIdNameMap[agents[i].LLMId]; ok {
rows[i].LLMName = name
}
rows[i].AiProxyRoutingId = agents[i].AiproxyRoutingId
rows[i].AiproxyVirtualKeyId = agents[i].AiproxyVirtualKeyId
rows[i].DefaultAgent = agents[i].DefaultAgent
}
}
@@ -577,6 +524,12 @@ func friendlyChatStreamError(err error) string {
// process 处理用户请求(多轮工具调用,直到模型不再发 tool_calls 或达到上限)
func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCredential, req *api.LLMMCPAgentRequestInput, onStream func(string) error) (*api.MCPAgentResponse, error) {
if strings.TrimSpace(mcp.AiproxyRoutingId) == "" {
return nil, errors.Wrap(httperrors.ErrInvalidStatus, "mcp agent has no aiproxy_routing_id; update the agent to bind an AI gateway routing rule")
}
if strings.TrimSpace(mcp.AiproxyVirtualKeyId) == "" {
return nil, errors.Wrap(httperrors.ErrInvalidStatus, "mcp agent has no aiproxy_virtual_key_id; update the agent to bind an AI gateway API key")
}
mcpServerUrl, err := mcp.GetMcpServerUrl(ctx, userCred)
if err != nil {
return nil, errors.Wrap(err, "GetMcpServerUrl")

View File

@@ -33,13 +33,13 @@ func (o *MCPAgentShowOptions) Params() (jsonutils.JSONObject, error) {
type MCPAgentCreateOptions struct {
apis.SharableVirtualResourceCreateInput
LlmId string `help:"LLM 实例 ID如果提供则自动获取 llm_url" json:"llm_id"`
LLM_URL string `help:"后端大模型的 base 请求地址" json:"llm_url"`
LLM_DRIVER string `help:"使用的模型驱动,可以是 ollama 或 openai" json:"llm_driver" choices:"ollama|openai"`
MODEL string `help:"使用的模型名称" json:"model"`
API_KEY string `help:"访问大模型的密钥" json:"api_key"`
McpServer string `help:"mcp 服务器的后端地址" json:"mcp_server"`
DefaultAgent bool `help:"set as default MCP agent (only one can be true globally)" json:"default_agent"`
LLM_URL string `help:"AI 网关 OpenAI 兼容 base 请求地址" json:"llm_url"`
LLM_DRIVER string `help:"使用的大模型驱动,固定为 openai" json:"llm_driver" choices:"openai"`
MODEL string `help:"使用的模型名称" json:"model"`
McpServer string `help:"mcp 服务器的后端地址" json:"mcp_server"`
AiProxyRoutingId string `help:"关联的 AI 网关路由规则 ID" json:"aiproxy_routing_id"`
AiproxyVirtualKeyId string `help:"关联的 AI 网关 API Key ID" json:"aiproxy_virtual_key_id"`
DefaultAgent bool `help:"set as default MCP agent (only one can be true globally)" json:"default_agent"`
}
func (o *MCPAgentCreateOptions) Params() (jsonutils.JSONObject, error) {
@@ -49,14 +49,14 @@ func (o *MCPAgentCreateOptions) Params() (jsonutils.JSONObject, error) {
type MCPAgentUpdateOptions struct {
apis.SharableVirtualResourceBaseUpdateInput
ID string
LlmId *string `help:"LLM 实例 ID如果提供则自动获取 llm_url" json:"llm_id,omitempty"`
LlmUrl *string `help:"后端大模型的 base 请求地址" json:"llm_url,omitempty"`
LlmDriver *string `help:"使用的模型驱动,可以是 ollama 或 openai" json:"llm_driver,omitempty" choices:"ollama|openai"`
Model *string `help:"使用的模型名称" json:"model,omitempty"`
ApiKey *string `help:"访问大模型的密钥" json:"api_key,omitempty"`
McpServer *string `help:"mcp 服务器的后端地址" json:"mcp_server,omitempty"`
DefaultAgent *bool `help:"set as default MCP agent (only one can be true globally)" json:"default_agent,omitempty"`
ID string
LlmUrl *string `help:"AI 网关 OpenAI 兼容 base 请求地址" json:"llm_url,omitempty"`
LlmDriver *string `help:"使用的大模型驱动,固定为 openai" json:"llm_driver,omitempty" choices:"openai"`
Model *string `help:"使用的模型名称" json:"model,omitempty"`
McpServer *string `help:"mcp 服务器的后端地址" json:"mcp_server,omitempty"`
AiProxyRoutingId *string `help:"关联的 AI 网关路由规则 ID" json:"aiproxy_routing_id,omitempty"`
AiproxyVirtualKeyId *string `help:"关联的 AI 网关 API Key ID" json:"aiproxy_virtual_key_id,omitempty"`
DefaultAgent *bool `help:"set as default MCP agent (only one can be true globally)" json:"default_agent,omitempty"`
}
func (o *MCPAgentUpdateOptions) GetId() string {
@@ -66,9 +66,6 @@ func (o *MCPAgentUpdateOptions) GetId() string {
func (o *MCPAgentUpdateOptions) Params() (jsonutils.JSONObject, error) {
// 只包含非空字段
params := jsonutils.NewDict()
if o.LlmId != nil && len(*o.LlmId) > 0 {
params.Set("llm_id", jsonutils.NewString(*o.LlmId))
}
if o.LlmUrl != nil && len(*o.LlmUrl) > 0 {
params.Set("llm_url", jsonutils.NewString(*o.LlmUrl))
}
@@ -78,12 +75,15 @@ func (o *MCPAgentUpdateOptions) Params() (jsonutils.JSONObject, error) {
if o.Model != nil && len(*o.Model) > 0 {
params.Set("model", jsonutils.NewString(*o.Model))
}
if o.ApiKey != nil && len(*o.ApiKey) > 0 {
params.Set("api_key", jsonutils.NewString(*o.ApiKey))
}
if o.McpServer != nil && len(*o.McpServer) > 0 {
params.Set("mcp_server", jsonutils.NewString(*o.McpServer))
}
if o.AiProxyRoutingId != nil && len(*o.AiProxyRoutingId) > 0 {
params.Set("aiproxy_routing_id", jsonutils.NewString(*o.AiProxyRoutingId))
}
if o.AiproxyVirtualKeyId != nil && len(*o.AiproxyVirtualKeyId) > 0 {
params.Set("aiproxy_virtual_key_id", jsonutils.NewString(*o.AiproxyVirtualKeyId))
}
if o.DefaultAgent != nil {
params.Set("default_agent", jsonutils.NewBool(*o.DefaultAgent))
}