mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/yunionio/cloudpods.git
synced 2026-09-20 08:03:53 +08:00
feat(llm): route MCP agent via aiproxy virtual key and routing id (#25665)
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user