Fix(llm): fix bugs & apikey encrypt & support history for mcp-agent-chat & delete modelname in llmSku (#24149)

* fix(llm): mcp-agent apiKey encrypt

* fix(llm): error-handle when instant-model import

* feature(llm): add get-available-network for llm

* feature(llm): support history for mcp-agent-chat

* fix(llm): llm create with net instead of networktype & networkid

* fix(llm): delete modelName in llmSku & fix some bugs
This commit is contained in:
cwz_eikoh
2026-01-29 10:50:00 +08:00
committed by GitHub
parent b968f9a05f
commit e254c2ccf5
24 changed files with 561 additions and 130 deletions
+1
View File
@@ -18,6 +18,7 @@ func init() {
cmd.BatchPerform("start", new(options.LLMStartOptions))
cmd.Get("probed-models", new(options.LLMIdOptions))
cmd.Get("url", new(options.LLMIdOptions))
cmd.Custom(shell.CustomActionGet, "available-network", new(options.LLMAvailableNetworkOptions))
cmd.Perform("save-instant-model", new(options.LLMSaveInstantModelOptions))
cmd.Perform("quick-models", new(options.LLMQuickModelsOptions))
}
+14 -5
View File
@@ -4,7 +4,7 @@ import (
"bufio"
"fmt"
"io"
"net/url"
"net/http"
"strings"
"yunion.io/x/onecloud/cmd/climc/shell"
@@ -36,15 +36,24 @@ func chatStream(s *mcclient.ClientSession, args *options.MCPAgentMCPAgentRequest
return err
}
path := fmt.Sprintf("/mcp_agents/%s/chat-stream?message=%s", id, url.QueryEscape(args.Message))
bodyJSON, err := args.Params()
if err != nil {
return fmt.Errorf("failed to build request params: %v", err)
}
headers := http.Header{}
headers.Set("Content-Type", "application/json")
body := strings.NewReader(bodyJSON.String())
path := fmt.Sprintf("/mcp_agents/%s/chat-stream", id)
resp, err := s.RawVersionRequest(
modules.MCPAgent.ServiceType(),
modules.MCPAgent.EndpointType(),
"GET",
"POST",
path,
nil,
nil,
headers,
body,
)
if err != nil {
return err
+2 -2
View File
@@ -4,6 +4,7 @@ import (
"time"
"yunion.io/x/onecloud/pkg/apis"
computeapi "yunion.io/x/onecloud/pkg/apis/compute"
"yunion.io/x/onecloud/pkg/cloudcommon/db/taskman"
)
@@ -64,8 +65,7 @@ type LLMBaseCreateInput struct {
PreferHost string `json:"prefer_host"`
AutoStart bool `json:"auto_start"`
NetworkType string `json:"network_type"`
NetworkId string `json:"network_id"`
Nets []*computeapi.NetworkConfig `json:"nets"`
BandwidthMB int `json:"bandwidth_mb"`
DebugMode bool `json:"debug_mode"`
+9 -1
View File
@@ -98,7 +98,14 @@ type LLMToolRequestInput struct {
}
type LLMMCPAgentRequestInput struct {
Message string `json:"message" help:"message to send to MCP agent"`
Message string `json:"message" help:"message to send to MCP agent"`
History []MCPAgentChatMessage `json:"history" help:"chat history"`
}
type MCPAgentChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
// ToolCalls []MCPAgentToolCallRecord `json:"tool_calls,omitempty"`
}
// MCPAgentResponse 表示 Agent 响应
@@ -115,6 +122,7 @@ type MCPAgentResponse struct {
// MCPAgentToolCallRecord 记录工具调用
type MCPAgentToolCallRecord struct {
Id string `json:"id,omitempty"`
ToolName string `json:"tool_name"`
Arguments map[string]interface{} `json:"arguments"`
Result string `json:"result"`
+2 -2
View File
@@ -17,10 +17,10 @@ const (
LLM_OLLAMA_HOST_MANIFESTS_DIR = "/manifests"
LLM_OLLAMA_CACHE_DIR = "/.llm_ollama_cache"
LLM_OLLAMA_CACHE_MOUNT_PATH = "/usr/local"
LLM_OLLAMA_LIBRARY_BASE_URL = `https://registry.ollama.ai/v2/library/%s`
LLM_OLLAMA_LIBRARY_BASE_URL = `https://registry.ollama.ai/v2/%s`
LLM_OLLAMA_BASE_PATH = "/root/.ollama/models"
LLM_OLLAMA_BLOBS_DIR = "/blobs"
LLM_OLLAMA_MANIFESTS_BASE_PATH = "/manifests/registry.ollama.ai/library"
LLM_OLLAMA_MANIFESTS_BASE_PATH = "/manifests/registry.ollama.ai"
)
const (
+4 -5
View File
@@ -166,18 +166,17 @@ type LLMSkuListInput struct {
type LLMSkuCreateInput struct {
LLMSKuBaseCreateInput
MountedModelResourceCreateInput
LLMImageId string `json:"llm_image_id"`
LLMType string `json:"llm_type"`
LLMModelName string `json:"llm_model_name"`
LLMImageId string `json:"llm_image_id"`
LLMType string `json:"llm_type"`
}
type LLMSkuUpdateInput struct {
LLMSkuBaseUpdateInput
MountedModelResourceUpdateInput
LLMImageId string `json:"llm_image_id"`
LLMModelName string `json:"llm_model_name"`
LLMImageId string `json:"llm_image_id"`
}
// type LLMModelCloneInput struct {
+7
View File
@@ -220,6 +220,13 @@ func (o *ollama) NewUserMessage(content string) models.ILLMChatMessage {
}
}
func (o *ollama) NewAssistantMessage(content string) models.ILLMChatMessage {
return &OllamaChatMessage{
Role: "assistant",
Content: content,
}
}
func (o *ollama) NewAssistantMessageWithToolCalls(toolCalls []models.ILLMToolCall) models.ILLMChatMessage {
// to ollama tool calls
ollamaToolCalls := make([]OllamaToolCall, len(toolCalls))
+19 -4
View File
@@ -194,8 +194,12 @@ func (o *openai) doChatStreamRequest(ctx context.Context, mcpAgent *models.SMCPA
return errors.Wrap(err, "create request")
}
httpReq.Header.Set("Content-Type", "application/json")
if mcpAgent.ApiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+mcpAgent.ApiKey)
apiKey, err := mcpAgent.GetApiKey()
if err != nil {
return err
}
if apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
}
client := &http.Client{
@@ -277,8 +281,12 @@ func (o *openai) doChatRequest(ctx context.Context, mcpAgent *models.SMCPAgent,
return nil, errors.Wrap(err, "create request")
}
httpReq.Header.Set("Content-Type", "application/json")
if mcpAgent.ApiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+mcpAgent.ApiKey)
apiKey, err := mcpAgent.GetApiKey()
if err != nil {
return nil, errors.Wrap(err, "get apiKey")
}
if apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
}
client := &http.Client{
@@ -319,6 +327,13 @@ func (o *openai) NewUserMessage(content string) models.ILLMChatMessage {
}
}
func (o *openai) NewAssistantMessage(content string) models.ILLMChatMessage {
return &OpenAIChatMessage{
Role: "assistant",
Content: content,
}
}
func (o *openai) NewAssistantMessageWithToolCalls(toolCalls []models.ILLMToolCall) models.ILLMChatMessage {
openaiToolCalls := make([]OpenAIToolCall, len(toolCalls))
for i, tc := range toolCalls {
+30 -17
View File
@@ -48,7 +48,7 @@ func (o *ollama) GetContainerSpec(ctx context.Context, llm *models.SLLM, image *
},
}
if len(devices) == 0 && len(*sku.Devices) > 0 {
if len(devices) == 0 && (sku.Devices != nil && len(*sku.Devices) > 0) {
for i := range *sku.Devices {
index := i
spec.Devices = append(spec.Devices, &computeapi.ContainerDevice{
@@ -159,7 +159,8 @@ func (o *ollama) GetContainerSpec(ctx context.Context, llm *models.SLLM, image *
func (o *ollama) DownloadModel(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, tmpDir string, modelName string, modelTag string) (string, []string, error) {
// 1. download manifest from registry
manifestsUrl := fmt.Sprintf(api.LLM_OLLAMA_LIBRARY_BASE_URL, fmt.Sprintf("%s/manifests/%s", modelName, modelTag))
namespace, repo := getNamespaceAndRepo(modelName)
manifestsUrl := fmt.Sprintf(api.LLM_OLLAMA_LIBRARY_BASE_URL, fmt.Sprintf("%s/%s/manifests/%s", namespace, repo, modelTag))
log.Infof("Downloading manifest from %s", manifestsUrl)
manifestContent, err := llm.HttpGet(ctx, manifestsUrl)
@@ -187,9 +188,9 @@ func (o *ollama) DownloadModel(ctx context.Context, userCred mcclient.TokenCrede
// 4. create directory structure
// tmpDir/blobs/
// tmpDir/manifests/registry.ollama.ai/library/<modelName>/<modelTag>
// tmpDir/manifests/registry.ollama.ai/<namespace>/<repo>
blobsDir := path.Join(tmpDir, "blobs")
manifestsDir := path.Join(tmpDir, api.LLM_OLLAMA_MANIFESTS_BASE_PATH, modelName)
manifestsDir := path.Join(tmpDir, api.LLM_OLLAMA_MANIFESTS_BASE_PATH, namespace, repo)
if err := os.MkdirAll(blobsDir, 0755); err != nil {
return "", nil, errors.Wrapf(err, "failed to create blobs directory %s", blobsDir)
}
@@ -210,8 +211,8 @@ func (o *ollama) DownloadModel(ctx context.Context, userCred mcclient.TokenCrede
}
// download blob from registry
// URL format: https://registry.ollama.ai/v2/library/<modelName>/blobs/<digest>
blobUrl := fmt.Sprintf(api.LLM_OLLAMA_LIBRARY_BASE_URL, fmt.Sprintf("%s/blobs/%s", modelName, digest))
// URL format: https://registry.ollama.ai/v2/<namespace>/<repo>/blobs/<digest>
blobUrl := fmt.Sprintf(api.LLM_OLLAMA_LIBRARY_BASE_URL, fmt.Sprintf("%s/%s/blobs/%s", namespace, repo, digest))
log.Infof("Downloading blob %s from %s", blobFileName, blobUrl)
if err := llm.HttpDownloadFile(ctx, blobUrl, blobPath); err != nil {
@@ -232,7 +233,7 @@ func (o *ollama) DownloadModel(ctx context.Context, userCred mcclient.TokenCrede
blobFileName := strings.Replace(blob, ":", "-", 1)
mounts[i] = path.Join(api.LLM_OLLAMA_BASE_PATH, api.LLM_OLLAMA_BLOBS_DIR, blobFileName)
}
mounts = append(mounts, path.Join(api.LLM_OLLAMA_BASE_PATH, api.LLM_OLLAMA_MANIFESTS_BASE_PATH, modelName, modelTag))
mounts = append(mounts, path.Join(api.LLM_OLLAMA_BASE_PATH, api.LLM_OLLAMA_MANIFESTS_BASE_PATH, namespace, repo, modelTag))
return modelId, mounts, nil
}
@@ -245,7 +246,8 @@ func (o *ollama) PreInstallModel(ctx context.Context, userCred mcclient.TokenCre
}
// mkdir llm-registry-base-path / modelname
mkdirReigtryBasePaht := fmt.Sprintf("mkdir -p %s", path.Join(api.LLM_OLLAMA_BASE_PATH, api.LLM_OLLAMA_MANIFESTS_BASE_PATH, instMdl.ModelName))
namespace, repo := getNamespaceAndRepo(instMdl.ModelName)
mkdirReigtryBasePaht := fmt.Sprintf("mkdir -p %s", path.Join(api.LLM_OLLAMA_BASE_PATH, api.LLM_OLLAMA_MANIFESTS_BASE_PATH, namespace, repo))
_, err = exec(ctx, lc.CmpId, mkdirReigtryBasePaht, 10)
if err != nil {
return errors.Wrap(err, "failed to mkdir llm-registry-base-path / modelname")
@@ -296,13 +298,11 @@ func (o *ollama) GetInstantModelIdByPostOverlay(postOverlay *commonapi.Container
for k := range postOverlay.Image.PathMap {
idx := strings.Index(k, api.LLM_OLLAMA_MANIFESTS_BASE_PATH)
if idx != -1 {
suffix := k[idx+len(api.LLM_OLLAMA_MANIFESTS_BASE_PATH):]
parts := strings.Split(strings.Trim(suffix, "/"), "/")
if len(parts) >= 2 {
modelName := parts[len(parts)-2]
modelTag := parts[len(parts)-1]
log.Infof("In GetInstantModelIdByPostOverlay, Extracted modelName: %s, modelTag: %s, Got modelId: %s", modelName, modelTag, mdlNameToId[modelName+":"+modelTag])
return mdlNameToId[modelName+":"+modelTag]
path := k[idx:]
nameTag := parseModelName(path)
if nameTag != "" {
log.Infof("In GetInstantModelIdByPostOverlay, Extracted nameTag: %s, Got modelId: %s", nameTag, mdlNameToId[nameTag])
return mdlNameToId[nameTag]
}
}
}
@@ -320,7 +320,8 @@ func (o *ollama) DetectModelPaths(ctx context.Context, userCred mcclient.TokenCr
for idx, blob := range pkgInfo.Blobs {
originBlobs[idx] = path.Join(api.LLM_OLLAMA_BASE_PATH, api.LLM_OLLAMA_BLOBS_DIR, blob)
}
originManifests := path.Join(api.LLM_OLLAMA_BASE_PATH, api.LLM_OLLAMA_MANIFESTS_BASE_PATH, pkgInfo.Name, pkgInfo.Tag)
namespace, repo := getNamespaceAndRepo(pkgInfo.Name)
originManifests := path.Join(api.LLM_OLLAMA_BASE_PATH, api.LLM_OLLAMA_MANIFESTS_BASE_PATH, namespace, repo, pkgInfo.Tag)
var checks []string
for _, blob := range originBlobs {
@@ -509,7 +510,8 @@ func exec(ctx context.Context, containerId string, cmd string, timeoutSec int64)
}
func getManifestsPath(modelName, modelTag string) string {
return path.Join(api.LLM_OLLAMA_BASE_PATH, api.LLM_OLLAMA_MANIFESTS_BASE_PATH, modelName, modelTag)
namespace, repo := getNamespaceAndRepo(modelName)
return path.Join(api.LLM_OLLAMA_BASE_PATH, api.LLM_OLLAMA_MANIFESTS_BASE_PATH, namespace, repo, modelTag)
}
type Layer struct {
@@ -591,6 +593,9 @@ func parseModelName(path string) string {
name := model[:lastSlash]
tag := model[lastSlash+1:]
tag = strings.TrimRight(tag, `\`)
if after, ok := strings.CutPrefix(name, "library/"); ok {
name = after
}
return name + ":" + tag
}
return strings.TrimRight(model, `\`)
@@ -642,3 +647,11 @@ func (o *ollama) GetLLMUrl(ctx context.Context, userCred mcclient.TokenCredentia
return fmt.Sprintf("http://%s:%d", server.HostAccessIp, accessInfo.AccessPort), nil
}
}
func getNamespaceAndRepo(modelName string) (string, string) {
if strings.Contains(modelName, "/") {
parts := strings.Split(modelName, "/")
return parts[0], parts[1]
}
return "library", modelName
}
+9 -9
View File
@@ -41,15 +41,15 @@ type SDifySkuManager struct {
type SDifySku struct {
SLLMSkuBase
PostgresImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
RedisImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
NginxImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
DifyApiImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
DifyPluginImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
DifyWebImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
DifySandboxImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
DifySSRFImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
DifyWeaviateImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
PostgresImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
RedisImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
NginxImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
DifyApiImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
DifyPluginImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
DifyWebImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
DifySandboxImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
DifySSRFImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
DifyWeaviateImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
}
func (man *SDifySkuManager) ListItemFilter(
+31 -5
View File
@@ -361,11 +361,7 @@ func (llm *SLLM) GetLLMSku(skuId string) (*SLLMSku, error) {
func (llm *SLLM) GetLargeLanguageModelName(name string) (modelName string, modelTag string, err error) {
if name == "" {
sku, err := llm.GetLLMSku("")
if err != nil {
return "", "", err
}
name = sku.LLMModelName
return "", "", errors.Wrap(errors.ErrInvalidStatus, "model name is empty")
}
parts := strings.Split(name, ":")
modelName = parts[0]
@@ -578,3 +574,33 @@ func fetchNetworks(ctx context.Context, userCred mcclient.TokenCredential, netwo
}
return networks, nil
}
func (man *SLLMManager) GetAvailableNetwork(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject) (jsonutils.JSONObject, error) {
s := auth.GetSession(ctx, userCred, "")
ret := jsonutils.NewDict()
q := jsonutils.NewDict()
if query != nil {
q.Update(query)
}
q.Set("server_type", jsonutils.NewString(string(computeapi.NETWORK_TYPE_HOSTLOCAL)))
q.Set("is_auto_alloc", jsonutils.NewBool(true))
q.Set("status", jsonutils.NewString(computeapi.NETWORK_STATUS_AVAILABLE))
q.Set("limit", jsonutils.NewInt(1))
result, err := compute.Networks.List(s, q)
if err == nil && result.Total > 0 {
ret.Add(jsonutils.NewInt(int64(result.Total)), "auto_alloc_network_hostlocal_count")
}
q.Set("server_type", jsonutils.NewString(string(computeapi.NETWORK_TYPE_GUEST)))
q.Set("vpc_id", jsonutils.NewString(computeapi.DEFAULT_VPC_ID))
resultGuest, err := compute.Networks.List(s, q)
if err == nil && resultGuest.Total > 0 {
ret.Add(jsonutils.NewInt(int64(resultGuest.Total)), "auto_alloc_network_guest_count")
}
return ret, nil
}
+46 -11
View File
@@ -97,23 +97,58 @@ func (man *SLLMBaseManager) ValidateCreateData(ctx context.Context, userCred mcc
input.PreferHost = hostDetails.Id
}
if len(input.NetworkType) > 0 && !api.IsLLMSkuBaseNetworkType(input.NetworkType) {
return input, errors.Wrapf(httperrors.ErrInputParameter, "invalid network type %s", input.NetworkType)
}
if len(input.NetworkId) > 0 {
s := auth.GetSession(ctx, userCred, "")
netObj, err := compute.Networks.Get(s, input.NetworkId, nil)
if err != nil {
return input, errors.Wrapf(httperrors.ErrInputParameter, "invalid network_id %s", input.NetworkId)
// 处理网络配置
var firstNet *computeapi.NetworkConfig
if len(input.Nets) > 0 {
firstNet = input.Nets[0]
firstNet.Index = 0
if len(string(firstNet.NetType)) > 0 && !api.IsLLMSkuBaseNetworkType(string(firstNet.NetType)) {
return input, errors.Wrapf(httperrors.ErrInputParameter, "invalid network type %s", firstNet.NetType)
}
input.NetworkId, _ = netObj.GetString("id")
input.NetworkType, _ = netObj.GetString("server_type")
if len(firstNet.Network) > 0 {
s := auth.GetSession(ctx, userCred, "")
netObj, err := compute.Networks.Get(s, firstNet.Network, nil)
if err != nil {
return input, errors.Wrapf(httperrors.ErrInputParameter, "invalid network_id %s", firstNet.Network)
}
netId, _ := netObj.GetString("id")
netType, _ := netObj.GetString("server_type")
firstNet.Network = netId
if len(string(firstNet.NetType)) == 0 {
firstNet.NetType = computeapi.TNetworkType(netType)
}
}
} else {
return input, errors.Wrap(httperrors.ErrInputParameter, "nets cannot be empty")
}
return input, nil
}
func (llmBase *SLLMBase) CustomizeCreate(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data jsonutils.JSONObject) error {
err := llmBase.SVirtualResourceBase.CustomizeCreate(ctx, userCred, ownerId, query, data)
if err != nil {
return errors.Wrap(err, "SVirtualResourceBase.CustomizeCreate")
}
var input api.LLMBaseCreateInput
if err := data.Unmarshal(&input); err != nil {
return errors.Wrap(err, "unmarshal LLMBaseCreateInput")
}
if len(input.Nets) > 0 {
firstNet := input.Nets[0]
if len(string(firstNet.NetType)) > 0 {
llmBase.NetworkType = string(firstNet.NetType)
}
if len(firstNet.Network) > 0 {
llmBase.NetworkId = firstNet.Network
}
}
return nil
}
func GetServerIdsByHost(ctx context.Context, userCred mcclient.TokenCredential, hostId string) ([]string, error) {
s := auth.GetSession(ctx, userCred, options.Options.Region)
params := computeoptions.ServerListOptions{}
+16 -10
View File
@@ -109,21 +109,27 @@ func GetLLMBasePodCreateInput(
})
}
}
bandwidth := llmBase.BandwidthMb
if bandwidth == 0 {
bandwidth = skuBase.Bandwidth
var network *computeapi.NetworkConfig
if len(input.Nets) > 0 {
network = input.Nets[0]
networkCopy := *network
network = &networkCopy
network.Index = 0
}
network := &computeapi.NetworkConfig{
BwLimit: bandwidth,
NetType: computeapi.TNetworkType(llmBase.NetworkType),
bandwidth := input.BandwidthMB
if bandwidth == 0 && network.BwLimit != 0 {
bandwidth = network.BwLimit
}
if llmBase.NetworkType == string(computeapi.NETWORK_TYPE_HOSTLOCAL) {
if bandwidth == 0 && skuBase.Bandwidth != 0 {
bandwidth = skuBase.Bandwidth
}
network.BwLimit = bandwidth
networkType := string(network.NetType)
if networkType == string(computeapi.NETWORK_TYPE_HOSTLOCAL) {
network.PortMappings = portMappings
}
if len(llmBase.NetworkId) > 0 {
network.Network = llmBase.NetworkId
}
data.Networks = []*computeapi.NetworkConfig{
network,
+27
View File
@@ -57,6 +57,7 @@ type ILLMClient interface {
ChatStream(ctx context.Context, mcpAgent *SMCPAgent, messages interface{}, tools interface{}, onChunk func(ILLMChatResponse) error) error
NewUserMessage(content string) ILLMChatMessage
NewAssistantMessage(content string) ILLMChatMessage
NewAssistantMessageWithToolCalls(toolCalls []ILLMToolCall) ILLMChatMessage
NewToolMessage(toolId string, toolName string, content string) ILLMChatMessage
NewSystemMessage(content string) ILLMChatMessage
@@ -64,6 +65,32 @@ type ILLMClient interface {
ConvertMCPTools(mcpTools []mcp.Tool) []ILLMTool
}
type SLLMToolCall struct {
Id string
Function SLLMFunctionCall
}
func (tc *SLLMToolCall) GetId() string {
return tc.Id
}
func (tc *SLLMToolCall) GetFunction() ILLMFunctionCall {
return &tc.Function
}
type SLLMFunctionCall struct {
Name string
Arguments map[string]interface{}
}
func (fc *SLLMFunctionCall) GetName() string {
return fc.Name
}
func (fc *SLLMFunctionCall) GetArguments() map[string]interface{} {
return fc.Arguments
}
var (
llmClientDrivers = newDrivers()
)
+6
View File
@@ -207,6 +207,9 @@ func (llm *SLLM) HttpGet(ctx context.Context, url string) ([]byte, error) {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound {
return nil, httperrors.NewResourceNotFoundError("url %s not found", url)
}
return nil, errors.Errorf("unexpected status code: %d", resp.StatusCode)
}
@@ -231,6 +234,9 @@ func (llm *SLLM) HttpDownloadFile(ctx context.Context, url string, filePath stri
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound {
return errors.Wrapf(httperrors.ErrResourceNotFound, "url %s not found", url)
}
return errors.Errorf("unexpected status code: %d", resp.StatusCode)
}
+28 -5
View File
@@ -52,9 +52,8 @@ type SLLMSku struct {
SLLMSkuBase
SMountedModelsResource
LLMImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
LLMType string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
LLMModelName string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
LLMImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
LLMType string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"`
}
func (man *SLLMSkuManager) ListItemFilter(
@@ -161,10 +160,29 @@ func (man *SLLMSkuManager) ValidateCreateData(ctx context.Context, userCred mccl
return input, errors.Wrap(httperrors.ErrInputParameter, "llm_type must be one of "+strings.Join(api.LLM_CONTAINER_TYPES.List(), ","))
}
_, err = validators.ValidateModel(ctx, userCred, GetLLMImageManager(), &input.LLMImageId)
imgObj, err := validators.ValidateModel(ctx, userCred, GetLLMImageManager(), &input.LLMImageId)
if err != nil {
return input, errors.Wrapf(err, "validate image_id %s", input.LLMImageId)
}
llmImage := imgObj.(*SLLMImage)
if llmImage.LLMType != input.LLMType {
return input, errors.Wrapf(httperrors.ErrInvalidStatus, "image %s is not of type %s", input.LLMImageId, input.LLMType)
}
input.LLMImageId = llmImage.Id
if input.MountedModels != nil {
for i, mdl := range input.MountedModels {
instMdl, err := GetInstantModelManager().FetchByIdOrName(ctx, userCred, mdl)
if err != nil {
return input, errors.Wrapf(err, "validate mounted model %s", mdl)
}
instantModle := instMdl.(*SInstantModel)
if instantModle.LlmType != input.LLMType {
return input, errors.Wrapf(httperrors.ErrInvalidStatus, "mounted model %s is not of type %s", mdl, input.LLMType)
}
input.MountedModels[i] = instantModle.GetId()
}
}
input.Status = api.STATUS_READY
return input, nil
@@ -200,7 +218,12 @@ func (sku *SLLMSku) ValidateUpdateData(ctx context.Context, userCred mcclient.To
if err != nil {
return input, errors.Wrapf(err, "validate image_id %s", input.LLMImageId)
}
input.LLMImageId = imgObj.GetId()
llmImage := imgObj.(*SLLMImage)
if llmImage.LLMType != sku.LLMType {
return input, errors.Wrapf(httperrors.ErrInvalidStatus, "image %s is not of type %s", input.LLMImageId, sku.LLMType)
}
input.LLMImageId = llmImage.Id
log.Infof("update llm_image_id %s to %s", sku.LLMImageId, input.LLMImageId)
}
return input, nil
+180 -42
View File
@@ -10,6 +10,7 @@ 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"
@@ -68,6 +69,48 @@ type SMCPAgent struct {
McpServer string `width:"512" charset:"utf8" nullable:"false" list:"user" create:"optional" update:"user"`
}
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.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) GetApiKey() (string, error) {
if len(mcp.ApiKey) == 0 {
return "", nil
}
// try decrypt
key, err := seclib.DescryptAESBase64(mcp.Id, mcp.ApiKey)
if err == nil {
return key, nil
}
return mcp.ApiKey, nil
}
func (man *SMCPAgentManager) CustomizeHandlerInfo(info *appsrv.SHandlerInfo) {
man.SSharableVirtualResourceBaseManager.CustomizeHandlerInfo(info)
@@ -100,12 +143,20 @@ func (man *SMCPAgentManager) ValidateCreateData(ctx context.Context, userCred mc
}
input.LLMUrl = llmUrl
sku, err := llm.GetLLMSku("")
if err != nil {
return input, errors.Wrapf(err, "get LLM Sku from LLM %s", input.LLMId)
}
if len(input.Model) == 0 {
input.Model = sku.LLMModelName
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)
}
}
@@ -159,11 +210,22 @@ func (man *SMCPAgentManager) ValidateUpdateData(ctx context.Context, userCred mc
}
input.LLMUrl = &llmUrl
sku, err := llm.GetLLMSku("")
if err != nil {
return input, errors.Wrapf(err, "get LLM Sku from LLM %s", *input.LLMId)
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
}
input.Model = &sku.LLMModelName
}
// 如果更新 llm_driver,验证其值
@@ -294,9 +356,10 @@ func (mcp *SMCPAgent) GetDetailsToolRequest(
// return jsonutils.Marshal(result), nil
// }
func (mcp *SMCPAgent) GetDetailsChatStream(
func (mcp *SMCPAgent) PerformChatStream(
ctx context.Context,
userCred mcclient.TokenCredential,
query jsonutils.JSONObject,
input api.LLMMCPAgentRequestInput,
) (jsonutils.JSONObject, error) {
appParams := appsrv.AppContextGetParams(ctx)
@@ -361,11 +424,22 @@ func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCreden
systemPrompt := buildSystemPrompt()
// 初始化消息历史
messages := []ILLMChatMessage{
llmClient.NewSystemMessage(systemPrompt),
llmClient.NewUserMessage(req.Message),
messages := make([]ILLMChatMessage, 0)
messages = append(messages, llmClient.NewSystemMessage(systemPrompt))
// 处理历史消息
if len(req.History) > 0 {
historyMessages := processHistoryMessages(
req.History,
llmClient,
options.Options.MCPAgentUserCharLimit,
options.Options.MCPAgentAssistantCharLimit,
)
messages = append(messages, historyMessages...)
}
messages = append(messages, llmClient.NewUserMessage(req.Message))
// 记录工具调用
var toolCallRecords []api.MCPAgentToolCallRecord
@@ -406,37 +480,14 @@ func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCreden
toolCalls := resp.GetToolCalls()
log.Infof("Got %d tool calls from Phase 1", len(toolCalls))
// 将助手决定调用工具的消息加入历史
messages = append(messages, llmClient.NewAssistantMessageWithToolCalls(toolCalls))
// 执行每个工具调用
for _, tc := range toolCalls {
fc := tc.GetFunction()
toolName := fc.GetName()
arguments := fc.GetArguments()
if arguments == nil {
arguments = make(map[string]interface{})
}
log.Infof("Calling tool: %s with arguments: %v", toolName, arguments)
// 调用 MCP 工具
result, err := mcpClient.CallTool(ctx, toolName, arguments)
resultText := utils.FormatToolResult(toolName, result, err)
log.Infoln("Get result from mcp query", resultText)
// 记录
toolCallRecords = append(toolCallRecords, api.MCPAgentToolCallRecord{
ToolName: toolName,
Arguments: arguments,
Result: resultText,
})
// 将工具执行结果加入历史
messages = append(messages, llmClient.NewToolMessage(tc.GetId(), toolName, resultText))
toolCallRecords, toolMessages, err := processToolCalls(ctx, toolCalls, mcpClient, llmClient)
if err != nil {
return nil, errors.Wrap(err, "process tool calls")
}
// 将工具调用相关的消息加入历史
messages = append(messages, toolMessages...)
log.Infof("Phase 2: Streaming Response...")
var finalAnswer strings.Builder
@@ -472,3 +523,90 @@ func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCreden
func buildSystemPrompt() string {
return api.MCP_AGENT_SYSTEM_PROMPT
}
func processHistoryMessages(
history []api.MCPAgentChatMessage,
llmClient ILLMClient,
maxUserChars int,
maxAssistantChars int,
) []ILLMChatMessage {
if len(history) == 0 {
return []ILLMChatMessage{}
}
var userChars, assistantChars int
processedMessages := make([]ILLMChatMessage, 0)
// 从最新的消息开始遍历,保留最新消息,丢弃最旧消息
for i := len(history) - 1; i >= 0; i-- {
msg := history[i]
msgChars := len(msg.Content)
switch msg.Role {
case "user":
if userChars+msgChars > maxUserChars {
break
}
userChars += msgChars
processedMessages = append(processedMessages, llmClient.NewUserMessage(msg.Content))
case "assistant":
if assistantChars+msgChars > maxAssistantChars {
break
}
assistantChars += msgChars
if len(msg.Content) > 0 {
processedMessages = append(processedMessages, llmClient.NewAssistantMessage(msg.Content))
}
}
}
for i, j := 0, len(processedMessages)-1; i < j; i, j = i+1, j-1 {
processedMessages[i], processedMessages[j] = processedMessages[j], processedMessages[i]
}
return processedMessages
}
// processToolCalls 处理工具调用
func processToolCalls(
ctx context.Context,
toolCalls []ILLMToolCall,
mcpClient *utils.MCPClient,
llmClient ILLMClient,
) ([]api.MCPAgentToolCallRecord, []ILLMChatMessage, error) {
toolCallRecords := make([]api.MCPAgentToolCallRecord, 0)
messagesToAdd := make([]ILLMChatMessage, 0)
messagesToAdd = append(messagesToAdd, llmClient.NewAssistantMessageWithToolCalls(toolCalls))
// 执行每个工具调用
for _, tc := range toolCalls {
fc := tc.GetFunction()
toolName := fc.GetName()
arguments := fc.GetArguments()
if arguments == nil {
arguments = make(map[string]interface{})
}
log.Infof("Calling tool: %s with arguments: %v", toolName, arguments)
// 调用 MCP 工具
result, err := mcpClient.CallTool(ctx, toolName, arguments)
resultText := utils.FormatToolResult(toolName, result, err)
log.Infoln("Get result from mcp query", resultText)
toolCallRecords = append(toolCallRecords, api.MCPAgentToolCallRecord{
Id: tc.GetId(),
ToolName: toolName,
Arguments: arguments,
Result: resultText,
})
// 将工具执行结果加入历史
messagesToAdd = append(messagesToAdd, llmClient.NewToolMessage(tc.GetId(), toolName, resultText))
}
return toolCallRecords, messagesToAdd, nil
}
+3
View File
@@ -30,6 +30,9 @@ type LLMOptions struct {
// MCP Agent 配置
MCPServerURL string `help:"MCP Server URL" default:"http://default-mcp-server:30876"`
MCPAgentTimeout int `help:"MCP Agent request timeout in seconds" default:"120"`
MCPAgentUserCharLimit int `help:"MCP Agent user char limit" default:"3200"`
MCPAgentAssistantCharLimit int `help:"MCP Agent assistant char limit" default:"6400"`
}
var (
+35
View File
@@ -2,13 +2,18 @@ package service
import (
"context"
"fmt"
"net/http"
"yunion.io/x/jsonutils"
"yunion.io/x/onecloud/pkg/appsrv"
"yunion.io/x/onecloud/pkg/appsrv/dispatcher"
"yunion.io/x/onecloud/pkg/cloudcommon/db"
"yunion.io/x/onecloud/pkg/cloudcommon/db/taskman"
"yunion.io/x/onecloud/pkg/cloudcommon/policy"
"yunion.io/x/onecloud/pkg/httperrors"
"yunion.io/x/onecloud/pkg/llm/models"
"yunion.io/x/onecloud/pkg/mcclient/auth"
)
func handleOllamaRegistryYAML(ctx context.Context, w http.ResponseWriter, r *http.Request) {
@@ -17,6 +22,34 @@ func handleOllamaRegistryYAML(ctx context.Context, w http.ResponseWriter, r *htt
appsrv.Send(w, yamlContent)
}
func AddAvailableNetworkHandler(prefix string, app *appsrv.Application) {
app.AddHandler2("GET", fmt.Sprintf("%s/available-network", prefix), auth.Authenticate(handleLLMAvailableNetwork), nil, "get_llm_available_network", nil)
}
func handleLLMAvailableNetwork(ctx context.Context, w http.ResponseWriter, r *http.Request) {
userCred := auth.FetchUserCredential(ctx, policy.FilterPolicyCredential)
if userCred == nil {
httperrors.UnauthorizedError(ctx, w, "Unauthorized")
return
}
query, err := jsonutils.ParseQueryString(r.URL.RawQuery)
if err != nil {
httperrors.InvalidInputError(ctx, w, "Parse query string %q: %v", r.URL.RawQuery, err)
return
}
ret, err := models.GetLLMManager().GetAvailableNetwork(ctx, userCred, query)
if err != nil {
httperrors.GeneralServerError(ctx, w, err)
return
}
wrapped := jsonutils.NewDict()
if ret != nil {
wrapped.Add(ret, "llm")
}
appsrv.SendJSON(w, wrapped)
}
func InitHandlers(app *appsrv.Application, isSlave bool) {
db.InitAllManagers()
db.RegistUserCredCacheUpdater()
@@ -25,6 +58,8 @@ func InitHandlers(app *appsrv.Application, isSlave bool) {
app.AddHandler("GET", "/ollama-registry.yaml", handleOllamaRegistryYAML)
AddAvailableNetworkHandler(models.GetLLMManager().KeywordPlural(), app)
for _, manager := range []db.IModelManager{
taskman.TaskManager,
taskman.SubTaskManager,
+16
View File
@@ -1,6 +1,11 @@
package llm
import (
"fmt"
"yunion.io/x/jsonutils"
"yunion.io/x/onecloud/pkg/mcclient"
"yunion.io/x/onecloud/pkg/mcclient/modulebase"
"yunion.io/x/onecloud/pkg/mcclient/modules"
)
@@ -21,3 +26,14 @@ func init() {
type LLMManager struct {
modulebase.ResourceManager
}
func (this *LLMManager) GetAvailableNetwork(session *mcclient.ClientSession, params jsonutils.JSONObject) (jsonutils.JSONObject, error) {
path := fmt.Sprintf("/%s/available-network", this.KeywordPlural)
if params != nil {
qs := params.QueryString()
if len(qs) > 0 {
path = fmt.Sprintf("%s?%s", path, qs)
}
}
return modulebase.Get(this.ResourceManager, session, path, this.Keyword)
}
+18 -1
View File
@@ -2,7 +2,10 @@ package llm
import (
"yunion.io/x/jsonutils"
"yunion.io/x/pkg/errors"
computeapi "yunion.io/x/onecloud/pkg/apis/compute"
"yunion.io/x/onecloud/pkg/cloudcommon/cmdline"
"yunion.io/x/onecloud/pkg/mcclient/options"
)
@@ -38,7 +41,21 @@ type DifyCreateOptions struct {
}
func (o *DifyCreateOptions) Params() (jsonutils.JSONObject, error) {
return jsonutils.Marshal(o), nil
params := jsonutils.Marshal(o)
if len(o.Net) > 0 {
nets := make([]*computeapi.NetworkConfig, 0)
for i, n := range o.Net {
net, err := cmdline.ParseNetworkConfig(n, i)
if err != nil {
return nil, errors.Wrapf(err, "parse network config %s", n)
}
nets = append(nets, net)
}
params.(*jsonutils.JSONDict).Add(jsonutils.Marshal(nets), "nets")
}
return params, nil
}
func (o *DifyCreateOptions) GetCountParam() int {
+32 -3
View File
@@ -2,8 +2,11 @@ package llm
import (
"yunion.io/x/jsonutils"
"yunion.io/x/pkg/errors"
computeapi "yunion.io/x/onecloud/pkg/apis/compute"
api "yunion.io/x/onecloud/pkg/apis/llm"
"yunion.io/x/onecloud/pkg/cloudcommon/cmdline"
"yunion.io/x/onecloud/pkg/mcclient/options"
)
@@ -55,8 +58,7 @@ type LLMBaseCreateOptions struct {
ProjectId string
PreferHost string
NETWORK_TYPE string `json:"network_type" choices:"guest|hostlocal"`
NetworkId string `help:"id of network" json:"network_id"`
Net []string `help:"Network descriptions"`
BandwidthMb int
@@ -70,7 +72,21 @@ type LLMCreateOptions struct {
}
func (o *LLMCreateOptions) Params() (jsonutils.JSONObject, error) {
return jsonutils.Marshal(o), nil
params := jsonutils.Marshal(o)
if len(o.Net) > 0 {
nets := make([]*computeapi.NetworkConfig, 0)
for i, n := range o.Net {
net, err := cmdline.ParseNetworkConfig(n, i)
if err != nil {
return nil, errors.Wrapf(err, "parse network config %s", n)
}
nets = append(nets, net)
}
params.(*jsonutils.JSONDict).Add(jsonutils.Marshal(nets), "nets")
}
return params, nil
}
func (o *LLMCreateOptions) GetCountParam() int {
@@ -117,6 +133,19 @@ func (opts *LLMIdOptions) Params() (jsonutils.JSONObject, error) {
return jsonutils.Marshal(opts), nil
}
type LLMAvailableNetworkOptions struct {
NetworkType string `help:"network server_type filter, e.g. guest|hostlocal"`
VpcId string `help:"vpc id filter"`
}
func (opts *LLMAvailableNetworkOptions) GetId() string {
return ""
}
func (opts *LLMAvailableNetworkOptions) Params() (jsonutils.JSONObject, error) {
return options.StructToParams(opts)
}
type LLMSaveInstantModelOptions struct {
LLMIdOptions
+7 -6
View File
@@ -27,9 +27,10 @@ func (o *LLMSkuShowOptions) Params() (jsonutils.JSONObject, error) {
type LLMSkuCreateOptions struct {
LLMSkuBaseCreateOptions
LLM_IMAGE_ID string `json:"llm_image_id"`
LLM_TYPE string `json:"llm_type" choices:"ollama"`
LLM_MODEL_NAME string `help:"specific model of large language model, for example: qwen3:32b" json:"llm_model_name"`
MountedModels []string `help:"mounted models, <model_id> e.g. qwen2:0.5b-dup" json:"mounted_models"`
LLM_IMAGE_ID string `json:"llm_image_id"`
LLM_TYPE string `json:"llm_type" choices:"ollama"`
}
func (o *LLMSkuCreateOptions) Params() (jsonutils.JSONObject, error) {
@@ -38,6 +39,7 @@ func (o *LLMSkuCreateOptions) Params() (jsonutils.JSONObject, error) {
obj.Unmarshal(dict)
o.LLMSkuBaseCreateOptions.Params(dict)
fetchMountedModels(o.MountedModels, dict)
return dict, nil
}
@@ -56,10 +58,9 @@ func (o *LLMSkuDeleteOptions) Params() (jsonutils.JSONObject, error) {
type LLMSkuUpdateOptions struct {
LLMSkuBaseUpdateOptions
MountedModels []string `help:"mounted models, <model_id>@<model_name>:<model_tag> e.g. 6f48b936a09f@qwen2:0.5b" json:"mounted_models"`
MountedModels []string `help:"mounted models, <model_id> e.g. qwen2:0.5b-dup" json:"mounted_models"`
LlmImageId string
LlmModelName string
LlmImageId string `json:"llm_image_id"`
}
func (o *LLMSkuUpdateOptions) GetId() string {
+19 -2
View File
@@ -1,6 +1,7 @@
package llm
import (
"fmt"
"strings"
"yunion.io/x/jsonutils"
@@ -143,12 +144,28 @@ func (opts *MCPAgentToolRequestOptions) Params() (jsonutils.JSONObject, error) {
type MCPAgentMCPAgentRequestOptions struct {
MCPAgentIdOptions
Message string `help:"message to send to MCP agent" json:"message"`
MESSAGE string `help:"message to send to MCP agent" json:"message"`
History string `help:"chat history as JSON string, e.g. '[{\"role\":\"user\",\"content\":\"hello\"}]'" json:"history,omitempty"`
}
func (opts *MCPAgentMCPAgentRequestOptions) Params() (jsonutils.JSONObject, error) {
input := api.LLMMCPAgentRequestInput{
Message: opts.Message,
Message: opts.MESSAGE,
History: []api.MCPAgentChatMessage{},
}
if len(opts.History) > 0 {
historyJSON, err := jsonutils.ParseString(opts.History)
if err != nil {
return nil, fmt.Errorf("failed to parse history JSON: %v", err)
}
if historyJSON != nil {
err = historyJSON.Unmarshal(&input.History)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal history: %v", err)
}
}
}
return jsonutils.Marshal(input), nil
}