Automated cherry pick of #24025: feature(llm): add mcp-agent (#24026)

* feature(llm): add mcp-agent

* feature(llm): support openai in llm_client

* feature(llm): chat-stream demo
This commit is contained in:
cwz_eikoh
2026-01-12 10:46:06 +08:00
committed by GitHub
parent e1e91c0248
commit 0ff02020e7
17 changed files with 2398 additions and 6 deletions
+71
View File
@@ -0,0 +1,71 @@
package llm
import (
"fmt"
"io"
"net/url"
"yunion.io/x/onecloud/cmd/climc/shell"
"yunion.io/x/onecloud/pkg/mcclient"
modules "yunion.io/x/onecloud/pkg/mcclient/modules/llm"
base_options "yunion.io/x/onecloud/pkg/mcclient/options"
options "yunion.io/x/onecloud/pkg/mcclient/options/llm"
)
func init() {
cmd := shell.NewResourceCmd(&modules.MCPAgent)
cmd.List(new(options.MCPAgentListOptions))
cmd.Show(new(options.MCPAgentShowOptions))
cmd.Create(new(options.MCPAgentCreateOptions))
cmd.Update(new(options.MCPAgentUpdateOptions))
cmd.Delete(new(options.MCPAgentDeleteOptions))
cmd.Perform("public", &base_options.BasePublicOptions{})
cmd.Perform("private", &base_options.BaseIdOptions{})
cmd.Get("mcp-tools", new(options.MCPAgentIdOptions))
cmd.Get("tool-request", new(options.MCPAgentToolRequestOptions))
// cmd.Get("chat-test", new(options.MCPAgentChatTestOptions))
cmd.Get("request", new(options.MCPAgentMCPAgentRequestOptions))
shell.R(&options.MCPAgentChatTestOptions{}, "mcp-agent-chat", "Chat with MCP Agent (Stream)", func(s *mcclient.ClientSession, args *options.MCPAgentChatTestOptions) error {
id, err := modules.MCPAgent.GetId(s, args.ID, nil)
if err != nil {
return err
}
path := fmt.Sprintf("/mcp_agents/%s/chat-stream?message=%s", id, url.QueryEscape(args.Message))
resp, err := s.RawVersionRequest(
modules.MCPAgent.ServiceType(),
modules.MCPAgent.EndpointType(),
"GET",
path,
nil,
nil,
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
// Read error body
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Error: %s %s", resp.Status, string(body))
}
buffer := make([]byte, 1024)
for {
n, err := resp.Body.Read(buffer)
if n > 0 {
fmt.Print(string(buffer[:n]))
}
if err != nil {
if err == io.EOF {
break
}
return err
}
}
fmt.Println()
return nil
})
}
+123
View File
@@ -0,0 +1,123 @@
package llm
import (
"yunion.io/x/pkg/util/sets"
"yunion.io/x/onecloud/pkg/apis"
)
// LLMClientType 定义 LLM 驱动类型
type LLMClientType string
const (
LLM_CLIENT_OLLAMA LLMClientType = "ollama"
LLM_CLIENT_OPENAI LLMClientType = "openai"
MCP_AGENT_SYSTEM_PROMPT = `你是一个 Cloudpods 云平台管理助手。你可以使用提供的工具来帮助用户管理云资源。
## 你的能力
- 查询云平台资源(虚拟机、镜像、网络、存储、区域等)
- 管理虚拟机(创建、启动、停止、重启、删除、重置密码)
- 获取虚拟机监控信息和实时统计数据
## 工作流程
1. 理解用户的需求
2. 选择合适的工具来完成任务
3. 分析工具返回的结果
4. 如果需要更多信息,继续调用其他工具
5. 最后用自然语言总结结果给用户
## 注意事项
- 认证信息已由系统自动处理,调用工具时无需提供认证参数
- 如果工具调用失败,尝试分析错误原因并告知用户
- 回复时使用中文,语言简洁明了
`
)
var (
LLM_CLIENT_TYPES = sets.NewString(
string(LLM_CLIENT_OLLAMA),
string(LLM_CLIENT_OPENAI),
)
)
// IsLLMClientType 检查给定的字符串是否是有效的 LLM 驱动类型
func IsLLMClientType(t string) bool {
return LLM_CLIENT_TYPES.Has(t)
}
// MCP Agent 配置相关的 API 定义
type MCPAgentListInput struct {
apis.SharableVirtualResourceListInput
LLMDriver string `json:"llm_driver"`
}
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 服务器的后端地址"`
}
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 服务器的后端地址"`
}
type MCPAgentDetails struct {
apis.SharableVirtualResourceDetails
LLMUrl string `json:"llm_url"`
LLMDriver string `json:"llm_driver"`
Model string `json:"model"`
ApiKey string `json:"api_key"`
McpServer string `json:"mcp_server"`
}
type LLMToolRequestInput struct {
ToolName string `json:"tool_name"`
Arguments map[string]interface{} `json:"arguments"`
}
type LLMChatTestInput struct {
Message string `json:"message" help:"test message to send to LLM"`
}
type LLMMCPAgentRequestInput struct {
Query string `json:"query" help:"query to send to MCP agent"`
}
// MCPAgentResponse 表示 Agent 响应
type MCPAgentResponse struct {
// Success 是否成功
Success bool `json:"success"`
// Answer 自然语言回答
Answer string `json:"answer"`
// Error 错误信息
Error string `json:"error,omitempty"`
// ToolCalls 工具调用记录
ToolCalls []MCPAgentToolCallRecord `json:"tool_calls,omitempty"`
}
// MCPAgentToolCallRecord 记录工具调用
type MCPAgentToolCallRecord struct {
ToolName string `json:"tool_name"`
Arguments map[string]interface{} `json:"arguments"`
Result string `json:"result"`
}
const (
// MCPAgentMaxIterations 最大迭代次数,防止无限循环
MCPAgentMaxIterations = 10
)
+1
View File
@@ -8,6 +8,7 @@ const (
LLM_OLLAMA_CREATE_ACTION = "create"
LLM_OLLAMA_EXPORT_ENV_KEY = "OLLAMA_HOST"
LLM_OLLAMA_EXPORT_ENV_VALUE = "0.0.0.0:11434"
LLM_OLLAMA_DEFAULT_PORT = 11434
)
const (
+1
View File
@@ -0,0 +1 @@
package llm_client
+471
View File
@@ -0,0 +1,471 @@
package llm_client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/mark3labs/mcp-go/mcp"
"yunion.io/x/pkg/errors"
api "yunion.io/x/onecloud/pkg/apis/llm"
"yunion.io/x/onecloud/pkg/llm/models"
)
func init() {
models.RegisterLLMClientDriver(newOllama())
}
type ollama struct{}
func newOllama() models.ILLMClient {
return new(ollama)
}
func (o *ollama) GetType() api.LLMClientType {
return api.LLM_CLIENT_OLLAMA
}
func convertMessages(messages interface{}) ([]OllamaChatMessage, error) {
// 转换 messages
var ollamaMessages []OllamaChatMessage
if msgs, ok := messages.([]OllamaChatMessage); ok {
ollamaMessages = msgs
} else if msgs, ok := messages.([]models.ILLMChatMessage); ok {
ollamaMessages = make([]OllamaChatMessage, len(msgs))
for i, msg := range msgs {
ollamaMessages[i] = OllamaChatMessage{
Role: msg.GetRole(),
Content: msg.GetContent(),
}
// 转换工具调用
if toolCalls := msg.GetToolCalls(); len(toolCalls) > 0 {
ollamaMessages[i].ToolCalls = make([]OllamaToolCall, len(toolCalls))
for j, tc := range toolCalls {
fc := tc.GetFunction()
ollamaMessages[i].ToolCalls[j] = OllamaToolCall{
Function: OllamaFunctionCall{
Name: fc.GetName(),
Arguments: fc.GetArguments(),
},
}
}
}
}
} else if msgs, ok := messages.([]interface{}); ok {
ollamaMessages = make([]OllamaChatMessage, 0, len(msgs))
for _, msg := range msgs {
if m, ok := msg.(OllamaChatMessage); ok {
ollamaMessages = append(ollamaMessages, m)
} else if m, ok := msg.(models.ILLMChatMessage); ok {
ollamaMessages = append(ollamaMessages, OllamaChatMessage{
Role: m.GetRole(),
Content: m.GetContent(),
})
}
}
} else {
return nil, errors.Error("invalid messages type, expected []OllamaChatMessage or []ILLMChatMessage")
}
return ollamaMessages, nil
}
func convertTool(tools interface{}) ([]OllamaTool, error) {
// 转换 tools
var ollamaTools []OllamaTool
if ts, ok := tools.([]OllamaTool); ok {
ollamaTools = ts
} else if ts, ok := tools.([]models.ILLMTool); ok {
ollamaTools = make([]OllamaTool, len(ts))
for i, t := range ts {
tf := t.GetFunction()
ollamaTools[i] = OllamaTool{
Type: t.GetType(),
Function: OllamaToolFunction{
Name: tf.GetName(),
Description: tf.GetDescription(),
Parameters: tf.GetParameters(),
},
}
}
} else if ts, ok := tools.([]interface{}); ok && ts != nil {
ollamaTools = make([]OllamaTool, 0, len(ts))
for _, tool := range ts {
if t, ok := tool.(OllamaTool); ok {
ollamaTools = append(ollamaTools, t)
} else if t, ok := tool.(models.ILLMTool); ok {
tf := t.GetFunction()
ollamaTools = append(ollamaTools, OllamaTool{
Type: t.GetType(),
Function: OllamaToolFunction{
Name: tf.GetName(),
Description: tf.GetDescription(),
Parameters: tf.GetParameters(),
},
})
}
}
} else if tools == nil {
ollamaTools = nil
} else {
return nil, errors.Error("invalid tools type, expected []OllamaTool or []ILLMTool or nil")
}
return ollamaTools, nil
}
func initRequestClient(ctx context.Context, endpoint, model string, stream bool, messages []OllamaChatMessage, tools []OllamaTool) (*http.Request, *http.Client, error) {
req := OllamaChatRequest{
Model: model,
Messages: messages,
Tools: tools,
Stream: stream,
}
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, errors.Wrap(err, "marshal request")
}
// 规范化 endpoint,确保以 / 结尾
endpoint = strings.TrimSuffix(endpoint, "/")
baseURL, err := url.Parse(endpoint)
if err != nil {
return nil, nil, errors.Wrapf(err, "invalid endpoint URL %s", endpoint)
}
// 构建完整的 URL
apiURL := baseURL.JoinPath("/api/chat")
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL.String(), bytes.NewReader(reqBody))
if err != nil {
return nil, nil, errors.Wrap(err, "create request")
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json")
client := &http.Client{
Timeout: 300 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
return httpReq, client, nil
}
func (o *ollama) Chat(ctx context.Context, mcpAgent *models.SMCPAgent, messages interface{}, tools interface{}) (models.ILLMChatResponse, error) {
ollamaMessages, err := convertMessages(messages)
if err != nil {
return nil, err
}
ollamaTools, err := convertTool(tools)
if err != nil {
return nil, err
}
httpReq, client, err := initRequestClient(ctx, mcpAgent.LLMUrl, mcpAgent.Model, false, ollamaMessages, ollamaTools)
// 调用底层方法
return o.doChatRequest(ctx, httpReq, client)
}
// doChatRequest 执行聊天请求
func (o *ollama) doChatRequest(ctx context.Context, httpReq *http.Request, client *http.Client) (*OllamaChatResponse, error) {
resp, err := client.Do(httpReq)
if err != nil {
return nil, errors.Wrap(err, "do request")
}
defer resp.Body.Close()
// 读取响应体以便错误处理
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrap(err, "read response body")
}
if resp.StatusCode != http.StatusOK {
return nil, errors.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body))
}
var chatResp OllamaChatResponse
if err := json.Unmarshal(body, &chatResp); err != nil {
return nil, errors.Wrapf(err, "decode response: %s", string(body))
}
return &chatResp, nil
}
func (o *ollama) NewUserMessage(content string) models.ILLMChatMessage {
return &OllamaChatMessage{
Role: "user",
Content: content,
}
}
func (o *ollama) NewAssistantMessageWithToolCalls(toolCalls []models.ILLMToolCall) models.ILLMChatMessage {
// to ollama tool calls
ollamaToolCalls := make([]OllamaToolCall, len(toolCalls))
for i, tc := range toolCalls {
if otc, ok := tc.(*OllamaToolCall); ok {
ollamaToolCalls[i] = *otc
} else {
fc := tc.GetFunction()
ollamaToolCalls[i] = OllamaToolCall{
Function: OllamaFunctionCall{
Name: fc.GetName(),
Arguments: fc.GetArguments(),
},
}
}
}
return &OllamaChatMessage{
Role: "assistant",
Content: "",
ToolCalls: ollamaToolCalls,
}
}
func (o *ollama) NewToolMessage(toolId string, toolName string, content string) models.ILLMChatMessage {
return &OllamaChatMessage{
Role: "tool",
Content: fmt.Sprintf("[%s] %s", toolName, content),
}
}
func (o *ollama) NewSystemMessage(content string) models.ILLMChatMessage {
return &OllamaChatMessage{
Role: "system",
Content: content,
}
}
func (o *ollama) ConvertMCPTools(mcpTools []mcp.Tool) []models.ILLMTool {
tools := make([]models.ILLMTool, len(mcpTools))
for i, t := range mcpTools {
var params map[string]interface{}
if t.RawInputSchema != nil {
_ = json.Unmarshal(t.RawInputSchema, &params)
} else {
schemaBytes, _ := json.Marshal(t.InputSchema)
_ = json.Unmarshal(schemaBytes, &params)
}
tools[i] = &OllamaTool{
Type: "function",
Function: OllamaToolFunction{
Name: t.Name,
Description: t.Description,
Parameters: params,
},
}
}
return tools
}
// OllamaChatMessage 表示聊天消息
// 实现 ILLMChatMessage 接口
type OllamaChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
}
// GetRole 实现 ILLMChatMessage 接口
func (m OllamaChatMessage) GetRole() string {
return m.Role
}
// GetContent 实现 ILLMChatMessage 接口
func (m OllamaChatMessage) GetContent() string {
return m.Content
}
// GetToolCalls 实现 ILLMChatMessage 接口
func (m OllamaChatMessage) GetToolCalls() []models.ILLMToolCall {
if len(m.ToolCalls) == 0 {
return nil
}
toolCalls := make([]models.ILLMToolCall, len(m.ToolCalls))
for i := range m.ToolCalls {
// 创建副本以避免引用问题
tc := m.ToolCalls[i]
toolCalls[i] = &tc
}
return toolCalls
}
// OllamaToolCall 表示工具调用
// 实现 ILLMToolCall 接口
type OllamaToolCall struct {
Function OllamaFunctionCall `json:"function"`
}
// GetFunction 实现 ILLMToolCall 接口
func (tc *OllamaToolCall) GetFunction() models.ILLMFunctionCall {
return &tc.Function
}
// GetId 实现 ILLMToolCall 接口
func (tc *OllamaToolCall) GetId() string {
return ""
}
// OllamaFunctionCall 表示函数调用详情
// 实现 ILLMFunctionCall 接口
type OllamaFunctionCall struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
// GetName 实现 ILLMFunctionCall 接口
func (fc *OllamaFunctionCall) GetName() string {
return fc.Name
}
// GetArguments 实现 ILLMFunctionCall 接口
func (fc *OllamaFunctionCall) GetArguments() map[string]interface{} {
return fc.Arguments
}
// OllamaTool 表示工具定义
// 实现 ILLMTool 接口
type OllamaTool struct {
Type string `json:"type"`
Function OllamaToolFunction `json:"function"`
}
// GetType 实现 ILLMTool 接口
func (t OllamaTool) GetType() string {
return t.Type
}
// GetFunction 实现 ILLMTool 接口
func (t OllamaTool) GetFunction() models.ILLMToolFunction {
return &t.Function
}
// OllamaToolFunction 表示工具函数定义
// 实现 ILLMToolFunction 接口
type OllamaToolFunction struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
}
// GetName 实现 ILLMToolFunction 接口
func (tf *OllamaToolFunction) GetName() string {
return tf.Name
}
// GetDescription 实现 ILLMToolFunction 接口
func (tf *OllamaToolFunction) GetDescription() string {
return tf.Description
}
// GetParameters 实现 ILLMToolFunction 接口
func (tf *OllamaToolFunction) GetParameters() map[string]interface{} {
return tf.Parameters
}
// OllamaChatRequest 表示聊天请求
type OllamaChatRequest struct {
Model string `json:"model"`
Messages []OllamaChatMessage `json:"messages"`
Tools []OllamaTool `json:"tools,omitempty"`
Stream bool `json:"stream"`
}
// OllamaChatResponse 表示聊天响应
type OllamaChatResponse struct {
Model string `json:"model"`
CreatedAt string `json:"created_at"`
Message OllamaChatMessage `json:"message"`
Done bool `json:"done"`
DoneReason string `json:"done_reason,omitempty"`
}
// GetContent 获取响应内容
func (r *OllamaChatResponse) GetContent() string {
return r.Message.Content
}
// HasToolCalls 检查响应是否包含工具调用
func (r *OllamaChatResponse) HasToolCalls() bool {
return len(r.Message.ToolCalls) > 0
}
// GetToolCalls 获取工具调用列表
func (r *OllamaChatResponse) GetToolCalls() []models.ILLMToolCall {
if len(r.Message.ToolCalls) == 0 {
return nil
}
toolCalls := make([]models.ILLMToolCall, len(r.Message.ToolCalls))
for i := range r.Message.ToolCalls {
toolCalls[i] = &r.Message.ToolCalls[i]
}
return toolCalls
}
func (o *ollama) ChatStream(ctx context.Context, mcpAgent *models.SMCPAgent, messages interface{}, tools interface{}, onChunk func(models.ILLMChatResponse) error) error {
ollamaMessages, err := convertMessages(messages)
if err != nil {
return err
}
ollamaTools, err := convertTool(tools)
if err != nil {
return err
}
httpReq, client, err := initRequestClient(ctx, mcpAgent.LLMUrl, mcpAgent.Model, true, ollamaMessages, ollamaTools)
return o.doChatStreamRequest(ctx, httpReq, client, onChunk)
}
func (o *ollama) doChatStreamRequest(ctx context.Context, httpReq *http.Request, client *http.Client, onChunk func(models.ILLMChatResponse) error) error {
resp, err := client.Do(httpReq)
if err != nil {
return errors.Wrap(err, "do request")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return errors.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body))
}
decoder := json.NewDecoder(resp.Body)
for {
var chunk OllamaChatResponse
if err := decoder.Decode(&chunk); err != nil {
if err == io.EOF {
break
}
return errors.Wrap(err, "decode stream chunk")
}
if onChunk != nil {
if err := onChunk(&chunk); err != nil {
return errors.Wrap(err, "process chunk")
}
}
if chunk.Done {
break
}
}
return nil
}
+517
View File
@@ -0,0 +1,517 @@
package llm_client
import (
"bufio"
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strings"
"time"
"github.com/mark3labs/mcp-go/mcp"
"yunion.io/x/pkg/errors"
api "yunion.io/x/onecloud/pkg/apis/llm"
"yunion.io/x/onecloud/pkg/llm/models"
)
func init() {
models.RegisterLLMClientDriver(newOpenAI())
}
type openai struct{}
func newOpenAI() models.ILLMClient {
return new(openai)
}
func (o *openai) GetType() api.LLMClientType {
return api.LLM_CLIENT_OPENAI
}
func (o *openai) Chat(ctx context.Context, mcpAgent *models.SMCPAgent, messages interface{}, tools interface{}) (models.ILLMChatResponse, error) {
// 转换 messages
var openaiMessages []OpenAIChatMessage
if msgs, ok := messages.([]OpenAIChatMessage); ok {
openaiMessages = msgs
} else if msgs, ok := messages.([]models.ILLMChatMessage); ok {
openaiMessages = make([]OpenAIChatMessage, len(msgs))
for i, msg := range msgs {
// Check if it's an OpenAIChatMessage to preserve ToolCallID
if om, ok := msg.(*OpenAIChatMessage); ok {
openaiMessages[i] = *om
} else {
// General conversion
openaiMessages[i] = OpenAIChatMessage{
Role: msg.GetRole(),
Content: msg.GetContent(),
}
// 转换工具调用
if toolCalls := msg.GetToolCalls(); len(toolCalls) > 0 {
openaiMessages[i].ToolCalls = make([]OpenAIToolCall, len(toolCalls))
for j, tc := range toolCalls {
fc := tc.GetFunction()
argsBytes, _ := json.Marshal(fc.GetArguments())
openaiMessages[i].ToolCalls[j] = OpenAIToolCall{
ID: tc.GetId(),
Type: "function",
Function: OpenAIFunctionCall{
Name: fc.GetName(),
Arguments: string(argsBytes),
},
}
}
}
}
}
} else {
return nil, errors.Error("invalid messages type")
}
// 转换 tools
var openaiTools []OpenAITool
if ts, ok := tools.([]OpenAITool); ok {
openaiTools = ts
} else if ts, ok := tools.([]models.ILLMTool); ok {
openaiTools = make([]OpenAITool, len(ts))
for i, t := range ts {
tf := t.GetFunction()
openaiTools[i] = OpenAITool{
Type: t.GetType(),
Function: OpenAIToolFunction{
Name: tf.GetName(),
Description: tf.GetDescription(),
Parameters: tf.GetParameters(),
},
}
}
} else if tools == nil {
openaiTools = nil
}
return o.doChatRequest(ctx, mcpAgent, openaiMessages, openaiTools)
}
func (o *openai) ChatStream(ctx context.Context, mcpAgent *models.SMCPAgent, messages interface{}, tools interface{}, onChunk func(models.ILLMChatResponse) error) error {
// 转换 messages
var openaiMessages []OpenAIChatMessage
if msgs, ok := messages.([]OpenAIChatMessage); ok {
openaiMessages = msgs
} else {
var ilMsgs []models.ILLMChatMessage
if msgs, ok := messages.([]models.ILLMChatMessage); ok {
ilMsgs = msgs
} else if msg, ok := messages.(models.ILLMChatMessage); ok {
ilMsgs = []models.ILLMChatMessage{msg}
} else {
return errors.Error("invalid messages type")
}
openaiMessages = make([]OpenAIChatMessage, len(ilMsgs))
for i, msg := range ilMsgs {
// Check if it's an OpenAIChatMessage to preserve ToolCallID
if om, ok := msg.(*OpenAIChatMessage); ok {
openaiMessages[i] = *om
} else {
// General conversion
openaiMessages[i] = OpenAIChatMessage{
Role: msg.GetRole(),
Content: msg.GetContent(),
}
// 转换工具调用
if toolCalls := msg.GetToolCalls(); len(toolCalls) > 0 {
openaiMessages[i].ToolCalls = make([]OpenAIToolCall, len(toolCalls))
for j, tc := range toolCalls {
fc := tc.GetFunction()
argsBytes, _ := json.Marshal(fc.GetArguments())
openaiMessages[i].ToolCalls[j] = OpenAIToolCall{
ID: tc.GetId(),
Type: "function",
Function: OpenAIFunctionCall{
Name: fc.GetName(),
Arguments: string(argsBytes),
},
}
}
}
}
}
}
// 转换 tools
var openaiTools []OpenAITool
if ts, ok := tools.([]OpenAITool); ok {
openaiTools = ts
} else if ts, ok := tools.([]models.ILLMTool); ok {
openaiTools = make([]OpenAITool, len(ts))
for i, t := range ts {
tf := t.GetFunction()
openaiTools[i] = OpenAITool{
Type: t.GetType(),
Function: OpenAIToolFunction{
Name: tf.GetName(),
Description: tf.GetDescription(),
Parameters: tf.GetParameters(),
},
}
}
} else if tools == nil {
openaiTools = nil
}
return o.doChatStreamRequest(ctx, mcpAgent, openaiMessages, openaiTools, onChunk)
}
func (o *openai) doChatStreamRequest(ctx context.Context, mcpAgent *models.SMCPAgent, messages []OpenAIChatMessage, tools []OpenAITool, onChunk func(models.ILLMChatResponse) error) error {
req := OpenAIChatRequest{
Model: mcpAgent.Model,
Messages: messages,
Tools: tools,
Stream: true,
}
reqBody, err := json.Marshal(req)
if err != nil {
return errors.Wrap(err, "marshal request")
}
endpoint := strings.TrimSuffix(mcpAgent.LLMUrl, "/")
// Default to /v1/chat/completions if not specified and not a custom path
if !strings.Contains(endpoint, "/chat/completions") {
if strings.HasSuffix(endpoint, "/v1") {
endpoint = endpoint + "/chat/completions"
} else {
endpoint = endpoint + "/v1/chat/completions"
}
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(reqBody))
if err != nil {
return errors.Wrap(err, "create request")
}
httpReq.Header.Set("Content-Type", "application/json")
if mcpAgent.ApiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+mcpAgent.ApiKey)
}
client := &http.Client{
// Stream request no timeout
Timeout: 0,
}
resp, err := client.Do(httpReq)
if err != nil {
return errors.Wrap(err, "do request")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return errors.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body))
}
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
line = strings.TrimSpace(line)
if line == "" {
continue
}
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
var chunk OpenAIChatStreamResponse
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return errors.Wrapf(err, "decode stream chunk: %s", data)
}
if onChunk != nil {
if err := onChunk(&chunk); err != nil {
return errors.Wrap(err, "process chunk")
}
}
}
if err := scanner.Err(); err != nil {
return errors.Wrap(err, "read stream")
}
return nil
}
func (o *openai) doChatRequest(ctx context.Context, mcpAgent *models.SMCPAgent, messages []OpenAIChatMessage, tools []OpenAITool) (*OpenAIChatResponse, error) {
req := OpenAIChatRequest{
Model: mcpAgent.Model,
Messages: messages,
Tools: tools,
}
reqBody, err := json.Marshal(req)
if err != nil {
return nil, errors.Wrap(err, "marshal request")
}
endpoint := strings.TrimSuffix(mcpAgent.LLMUrl, "/")
// Default to /v1/chat/completions if not specified and not a custom path
if !strings.Contains(endpoint, "/chat/completions") {
if strings.HasSuffix(endpoint, "/v1") {
endpoint = endpoint + "/chat/completions"
} else {
endpoint = endpoint + "/v1/chat/completions"
}
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(reqBody))
if err != nil {
return nil, errors.Wrap(err, "create request")
}
httpReq.Header.Set("Content-Type", "application/json")
if mcpAgent.ApiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+mcpAgent.ApiKey)
}
client := &http.Client{
Timeout: 300 * time.Second,
}
resp, err := client.Do(httpReq)
if err != nil {
return nil, errors.Wrap(err, "do request")
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrap(err, "read response body")
}
if resp.StatusCode != http.StatusOK {
return nil, errors.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body))
}
var chatResp OpenAIChatResponse
if err := json.Unmarshal(body, &chatResp); err != nil {
return nil, errors.Wrapf(err, "decode response: %s", string(body))
}
if len(chatResp.Choices) == 0 {
return nil, errors.Error("no choices in response")
}
return &chatResp, nil
}
func (o *openai) NewUserMessage(content string) models.ILLMChatMessage {
return &OpenAIChatMessage{
Role: "user",
Content: content,
}
}
func (o *openai) NewAssistantMessageWithToolCalls(toolCalls []models.ILLMToolCall) models.ILLMChatMessage {
openaiToolCalls := make([]OpenAIToolCall, len(toolCalls))
for i, tc := range toolCalls {
if otc, ok := tc.(*OpenAIToolCall); ok {
openaiToolCalls[i] = *otc
} else {
fc := tc.GetFunction()
argsBytes, _ := json.Marshal(fc.GetArguments())
openaiToolCalls[i] = OpenAIToolCall{
ID: tc.GetId(),
Type: "function",
Function: OpenAIFunctionCall{
Name: fc.GetName(),
Arguments: string(argsBytes),
},
}
}
}
return &OpenAIChatMessage{
Role: "assistant",
ToolCalls: openaiToolCalls,
}
}
func (o *openai) NewToolMessage(toolId string, toolName string, content string) models.ILLMChatMessage {
return &OpenAIChatMessage{
Role: "tool",
ToolCallID: toolId,
Content: content,
}
}
func (o *openai) NewSystemMessage(content string) models.ILLMChatMessage {
return &OpenAIChatMessage{
Role: "system",
Content: content,
}
}
func (o *openai) ConvertMCPTools(mcpTools []mcp.Tool) []models.ILLMTool {
tools := make([]models.ILLMTool, len(mcpTools))
for i, t := range mcpTools {
var params map[string]interface{}
if t.RawInputSchema != nil {
_ = json.Unmarshal(t.RawInputSchema, &params)
} else {
schemaBytes, _ := json.Marshal(t.InputSchema)
_ = json.Unmarshal(schemaBytes, &params)
}
tools[i] = &OpenAITool{
Type: "function",
Function: OpenAIToolFunction{
Name: t.Name,
Description: t.Description,
Parameters: params,
},
}
}
return tools
}
// Structures
type OpenAIChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
func (m *OpenAIChatMessage) GetRole() string { return m.Role }
func (m *OpenAIChatMessage) GetContent() string { return m.Content }
func (m *OpenAIChatMessage) GetToolCalls() []models.ILLMToolCall {
if len(m.ToolCalls) == 0 {
return nil
}
toolCalls := make([]models.ILLMToolCall, len(m.ToolCalls))
for i := range m.ToolCalls {
tc := m.ToolCalls[i]
toolCalls[i] = &tc
}
return toolCalls
}
type OpenAIToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function OpenAIFunctionCall `json:"function"`
}
func (tc *OpenAIToolCall) GetFunction() models.ILLMFunctionCall { return &tc.Function }
func (tc *OpenAIToolCall) GetId() string { return tc.ID }
type OpenAIFunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
func (fc *OpenAIFunctionCall) GetName() string { return fc.Name }
func (fc *OpenAIFunctionCall) GetArguments() map[string]interface{} {
var args map[string]interface{}
_ = json.Unmarshal([]byte(fc.Arguments), &args)
return args
}
type OpenAITool struct {
Type string `json:"type"`
Function OpenAIToolFunction `json:"function"`
}
func (t *OpenAITool) GetType() string { return t.Type }
func (t *OpenAITool) GetFunction() models.ILLMToolFunction { return &t.Function }
type OpenAIToolFunction struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
}
func (tf *OpenAIToolFunction) GetName() string { return tf.Name }
func (tf *OpenAIToolFunction) GetDescription() string { return tf.Description }
func (tf *OpenAIToolFunction) GetParameters() map[string]interface{} { return tf.Parameters }
type OpenAIChatRequest struct {
Model string `json:"model"`
Messages []OpenAIChatMessage `json:"messages"`
Tools []OpenAITool `json:"tools,omitempty"`
Stream bool `json:"stream,omitempty"`
}
type OpenAIChatResponse struct {
ID string `json:"id"`
Choices []OpenAIChoice `json:"choices"`
}
type OpenAIChoice struct {
Message OpenAIChatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
func (r *OpenAIChatResponse) GetContent() string {
if len(r.Choices) > 0 {
return r.Choices[0].Message.Content
}
return ""
}
func (r *OpenAIChatResponse) HasToolCalls() bool {
return len(r.Choices) > 0 && len(r.Choices[0].Message.ToolCalls) > 0
}
func (r *OpenAIChatResponse) GetToolCalls() []models.ILLMToolCall {
if len(r.Choices) == 0 {
return nil
}
return r.Choices[0].Message.GetToolCalls()
}
type OpenAIChatStreamResponse struct {
ID string `json:"id"`
Choices []OpenAIChatStreamChoice `json:"choices"`
}
type OpenAIChatStreamChoice struct {
Delta OpenAIChatStreamDelta `json:"delta"`
FinishReason string `json:"finish_reason"`
}
type OpenAIChatStreamDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
}
func (r *OpenAIChatStreamResponse) GetContent() string {
if len(r.Choices) > 0 {
return r.Choices[0].Delta.Content
}
return ""
}
func (r *OpenAIChatStreamResponse) HasToolCalls() bool {
return len(r.Choices) > 0 && len(r.Choices[0].Delta.ToolCalls) > 0
}
func (r *OpenAIChatStreamResponse) GetToolCalls() []models.ILLMToolCall {
if len(r.Choices) == 0 {
return nil
}
toolCalls := make([]models.ILLMToolCall, len(r.Choices[0].Delta.ToolCalls))
for i := range r.Choices[0].Delta.ToolCalls {
tc := r.Choices[0].Delta.ToolCalls[i]
toolCalls[i] = &tc
}
return toolCalls
}
+52
View File
@@ -3,6 +3,7 @@ package llm_container
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
@@ -594,3 +595,54 @@ func parseModelName(path string) string {
}
return strings.TrimRight(model, `\`)
}
func (o *ollama) GetLLMUrl(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM) (string, error) {
sku, err := llm.GetLLMSku("")
if err != nil {
return "", errors.Wrap(err, "get llm sku")
}
// 查询 accessinfo
accessInfo := &models.SAccessInfo{}
q := models.GetAccessInfoManager().Query().Equals("llm_id", llm.Id)
err = q.First(accessInfo)
if err != nil {
if errors.Cause(err) == sql.ErrNoRows {
// 如果没有 accessinfo,使用默认 localhost
server, err := llm.GetServer(ctx)
if err != nil {
return "", errors.Wrap(err, "get server")
}
// 从 IPs 字符串中选择第一个 IP
ips := strings.Split(strings.TrimSpace(server.IPs), ",")
if len(ips) == 0 || len(strings.TrimSpace(ips[0])) == 0 {
return "", errors.Error("server IPs is empty")
}
firstIP := strings.TrimSpace(ips[0])
return fmt.Sprintf("http://%s:%d", firstIP, api.LLM_OLLAMA_DEFAULT_PORT), nil
}
return "", errors.Wrap(err, "query accessinfo")
}
// 判断网络类型
networkType := sku.NetworkType
if networkType == string(computeapi.NETWORK_TYPE_GUEST) {
// guest 网络:使用 LLM IP + 默认端口
if len(llm.LLMIp) == 0 {
return "", errors.Error("LLM IP is empty for guest network")
}
return fmt.Sprintf("http://%s:%d", llm.LLMIp, api.LLM_OLLAMA_DEFAULT_PORT), nil
} else {
// hostlocal 或其他网络类型:使用宿主机 IP + 映射端口
server, err := llm.GetServer(ctx)
if err != nil {
return "", errors.Wrap(err, "get server")
}
if len(server.HostAccessIp) == 0 {
return "", errors.Error("host access IP is empty")
}
if accessInfo.AccessPort == 0 {
return "", errors.Error("access port is not set")
}
return fmt.Sprintf("http://%s:%d", server.HostAccessIp, accessInfo.AccessPort), nil
}
}
+4
View File
@@ -356,3 +356,7 @@ func (llm *SLLM) StartSyncStatusTask(ctx context.Context, userCred mcclient.Toke
}
return nil
}
func (llm *SLLM) GetLLMUrl(ctx context.Context, userCred mcclient.TokenCredential) (string, error) {
return llm.GetLLMContainerDriver().GetLLMUrl(ctx, userCred, llm)
}
+81
View File
@@ -0,0 +1,81 @@
package models
import (
"context"
"github.com/mark3labs/mcp-go/mcp"
llm "yunion.io/x/onecloud/pkg/apis/llm"
)
type ILLMChatMessage interface {
GetRole() string
GetContent() string
GetToolCalls() []ILLMToolCall
}
// ILLMToolCall 表示工具调用接口
type ILLMToolCall interface {
GetId() string
GetFunction() ILLMFunctionCall
}
// ILLMFunctionCall 表示函数调用详情接口
type ILLMFunctionCall interface {
GetName() string
GetArguments() map[string]interface{}
}
// ILLMTool 表示工具定义接口
type ILLMTool interface {
GetType() string
GetFunction() ILLMToolFunction
}
// ILLMToolFunction 表示工具函数定义接口
type ILLMToolFunction interface {
GetName() string
GetDescription() string
GetParameters() map[string]interface{}
}
// ILLMChatResponse 表示 LLM 聊天响应接口
// 参考 mcp_agent.go 中的 LLMChatResponse 接口设计
type ILLMChatResponse interface {
// HasToolCalls 检查响应是否包含工具调用
HasToolCalls() bool
// GetToolCalls 获取工具调用列表
GetToolCalls() []ILLMToolCall
// GetContent 获取响应内容
GetContent() string
}
type ILLMClient interface {
GetType() llm.LLMClientType
Chat(ctx context.Context, mcpAgent *SMCPAgent, messages interface{}, tools interface{}) (ILLMChatResponse, error)
ChatStream(ctx context.Context, mcpAgent *SMCPAgent, messages interface{}, tools interface{}, onChunk func(ILLMChatResponse) error) error
NewUserMessage(content string) ILLMChatMessage
NewAssistantMessageWithToolCalls(toolCalls []ILLMToolCall) ILLMChatMessage
NewToolMessage(toolId string, toolName string, content string) ILLMChatMessage
NewSystemMessage(content string) ILLMChatMessage
ConvertMCPTools(mcpTools []mcp.Tool) []ILLMTool
}
var (
llmClientDrivers = newDrivers()
)
func RegisterLLMClientDriver(drv ILLMClient) {
registerDriver(llmClientDrivers, drv.GetType(), drv)
}
func GetLLMClientDriver(typ llm.LLMClientType) ILLMClient {
return getDriver[llm.LLMClientType, ILLMClient](llmClientDrivers, typ)
}
func GetLLMClientDriverWithError(typ llm.LLMClientType) (ILLMClient, error) {
return getDriverWithError[llm.LLMClientType, ILLMClient](llmClientDrivers, typ)
}
+5 -6
View File
@@ -57,10 +57,6 @@ func getDriverWithError[K ~string, D any](drvs *drivers, typ K) (D, error) {
return drv.(D), nil
}
// type ILLMContainerPullModel interface {
// DownloadModel(ctx context.Context, userCred mcclient.TokenCredential, llm *SLLM, modelName string, modelTag string) error
// }
type ILLMContainerInstantApp interface {
GetProbedInstantModelsExt(ctx context.Context, userCred mcclient.TokenCredential, llm *SLLM, mdlIds ...string) (map[string]llm.LLMInternalInstantMdlInfo, error)
DetectModelPaths(ctx context.Context, userCred mcclient.TokenCredential, llm *SLLM, pkgInfo llm.LLMInternalInstantMdlInfo) ([]string, error)
@@ -83,9 +79,12 @@ type ILLMContainerDriver interface {
GetType() llm.LLMContainerType
GetContainerSpec(ctx context.Context, llm *SLLM, image *SLLMImage, sku *SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) *computeapi.PodContainerCreateInput
// ILLMContainerPullModel
ILLMContainerInstantApp
ILLMContainerMCPAgent
}
type ILLMContainerMCPAgent interface {
GetLLMUrl(ctx context.Context, userCred mcclient.TokenCredential, llm *SLLM) (string, error)
}
var (
+438
View File
@@ -0,0 +1,438 @@
package models
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"yunion.io/x/jsonutils"
"yunion.io/x/log"
"yunion.io/x/pkg/errors"
"yunion.io/x/sqlchemy"
api "yunion.io/x/onecloud/pkg/apis/llm"
"yunion.io/x/onecloud/pkg/appsrv"
"yunion.io/x/onecloud/pkg/cloudcommon/db"
"yunion.io/x/onecloud/pkg/httperrors"
"yunion.io/x/onecloud/pkg/llm/options"
"yunion.io/x/onecloud/pkg/llm/utils"
"yunion.io/x/onecloud/pkg/mcclient"
"yunion.io/x/onecloud/pkg/util/stringutils2"
)
func init() {
GetMCPAgentManager()
}
var mcpAgentManager *SMCPAgentManager
var mcpAgentWorkerMan *appsrv.SWorkerManager
func GetMCPAgentManager() *SMCPAgentManager {
if mcpAgentManager != nil {
return mcpAgentManager
}
mcpAgentManager = &SMCPAgentManager{
SSharableVirtualResourceBaseManager: db.NewSharableVirtualResourceBaseManager(
SMCPAgent{},
"mcp_agents_tbl",
"mcp_agent",
"mcp_agents",
),
}
mcpAgentManager.SetVirtualObject(mcpAgentManager)
return mcpAgentManager
}
type SMCPAgentManager struct {
db.SSharableVirtualResourceBaseManager
}
type SMCPAgent struct {
db.SSharableVirtualResourceBase
// LLMUrl 对应后端大模型的 base 请求地址
LLMUrl string `width:"512" charset:"utf8" nullable:"false" list:"user" create:"required" update:"user"`
// LLMDriver 对应使用的大模型驱动(llm_client),现在可以被设置为 ollama 或 openai
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" list:"user" create:"optional" update:"user"`
// McpServer 即 mcp 服务器的后端地址
McpServer string `width:"512" charset:"utf8" nullable:"false" list:"user" create:"optional" update:"user"`
}
func (man *SMCPAgentManager) CustomizeHandlerInfo(info *appsrv.SHandlerInfo) {
man.SSharableVirtualResourceBaseManager.CustomizeHandlerInfo(info)
// log.Infoln("query name of handler info", info.GetName(nil))
switch info.GetName(nil) {
case "get_specific":
info.SetProcessTimeout(time.Hour * 4).SetWorkerManager(mcpAgentWorkerMan)
}
}
func (man *SMCPAgentManager) ValidateCreateData(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, input *api.MCPAgentCreateInput) (*api.MCPAgentCreateInput, error) {
var err error
input.SharableVirtualResourceCreateInput, err = man.SSharableVirtualResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.SharableVirtualResourceCreateInput)
if err != nil {
return input, errors.Wrap(err, "validate SharableVirtualResourceCreateInput")
}
// 如果提供了 llm_id,则通过 LLM 获取 llm_url 和 model
if len(input.LLMId) > 0 {
llmObj, err := GetLLMManager().FetchByIdOrName(ctx, userCred, input.LLMId)
if err != nil {
return input, errors.Wrapf(err, "fetch LLM by id %s", input.LLMId)
}
llm := llmObj.(*SLLM)
llmUrl, err := llm.GetLLMUrl(ctx, userCred)
if err != nil {
return input, errors.Wrapf(err, "get LLM URL from LLM %s", input.LLMId)
}
input.LLMUrl = llmUrl
sku, err := llm.GetLLMSku("")
if err != nil {
return input, errors.Wrapf(err, "get LLM Sku from LLM %s", input.LLMId)
}
input.Model = sku.LLMModelName
}
// 验证 llm_url 不为空
if len(input.LLMUrl) == 0 {
return input, errors.Wrap(httperrors.ErrInputParameter, "llm_url is required (or provide llm_id to auto-fetch)")
}
// 验证 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)
}
// 验证 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
}
// 对于 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
}
func (man *SMCPAgentManager) ValidateUpdateData(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, input *api.MCPAgentUpdateInput) (*api.MCPAgentUpdateInput, error) {
var err error
input.SharableVirtualResourceCreateInput, err = man.SSharableVirtualResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.SharableVirtualResourceCreateInput)
if err != nil {
return input, errors.Wrap(err, "validate SharableVirtualResourceCreateInput")
}
// 如果提供了 llm_id,则通过 LLM 获取 llm_url 和 model
if input.LLMId != nil && len(*input.LLMId) > 0 {
llmObj, err := GetLLMManager().FetchByIdOrName(ctx, userCred, *input.LLMId)
if err != nil {
return input, errors.Wrapf(err, "fetch LLM by id %s", *input.LLMId)
}
llm := llmObj.(*SLLM)
llmUrl, err := llm.GetLLMUrl(ctx, userCred)
if err != nil {
return input, errors.Wrapf(err, "get LLM URL from LLM %s", *input.LLMId)
}
input.LLMUrl = &llmUrl
sku, err := llm.GetLLMSku("")
if err != nil {
return input, errors.Wrapf(err, "get LLM Sku from LLM %s", *input.LLMId)
}
input.Model = &sku.LLMModelName
}
// 如果更新 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)
}
}
return input, nil
}
func (man *SMCPAgentManager) ListItemFilter(
ctx context.Context,
q *sqlchemy.SQuery,
userCred mcclient.TokenCredential,
input api.MCPAgentListInput,
) (*sqlchemy.SQuery, error) {
q, err := man.SSharableVirtualResourceBaseManager.ListItemFilter(ctx, q, userCred, input.SharableVirtualResourceListInput)
if err != nil {
return nil, errors.Wrapf(err, "SSharableVirtualResourceBaseManager.ListItemFilter")
}
if len(input.LLMDriver) > 0 {
q = q.Equals("llm_driver", strings.ToLower(strings.TrimSpace(input.LLMDriver)))
}
return q, nil
}
func (manager *SMCPAgentManager) FetchCustomizeColumns(
ctx context.Context,
userCred mcclient.TokenCredential,
query jsonutils.JSONObject,
objs []interface{},
fields stringutils2.SSortedStrings,
isList bool,
) []api.MCPAgentDetails {
rows := make([]api.MCPAgentDetails, len(objs))
vrows := manager.SSharableVirtualResourceBaseManager.FetchCustomizeColumns(ctx, userCred, query, objs, fields, isList)
agents := []SMCPAgent{}
jsonutils.Update(&agents, objs)
for i := range rows {
rows[i].SharableVirtualResourceDetails = vrows[i]
if i < len(agents) {
rows[i].LLMUrl = agents[i].LLMUrl
rows[i].LLMDriver = agents[i].LLMDriver
rows[i].Model = agents[i].Model
rows[i].ApiKey = agents[i].ApiKey
rows[i].McpServer = agents[i].McpServer
}
}
return rows
}
func (mcp *SMCPAgent) GetLLMClientDriver() ILLMClient {
return GetLLMClientDriver(api.LLMClientType(mcp.LLMDriver))
}
func (mcp *SMCPAgent) GetDetailsMcpTools(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject) (jsonutils.JSONObject, error) {
// 创建 MCP 客户端
timeout := time.Duration(options.Options.MCPAgentTimeout) * time.Second
mcpClient := utils.NewMCPClient(options.Options.MCPServerURL, timeout, userCred)
// 获取工具列表
tools, err := mcpClient.ListTools(ctx)
if err != nil {
return nil, errors.Wrap(err, "list MCP tools")
}
return jsonutils.Marshal(tools), nil
}
func (mcp *SMCPAgent) GetDetailsToolRequest(
ctx context.Context,
userCred mcclient.TokenCredential,
input api.LLMToolRequestInput,
) (jsonutils.JSONObject, error) {
// 创建 MCP 客户端
timeout := time.Duration(options.Options.MCPAgentTimeout) * time.Second
mcpClient := utils.NewMCPClient(options.Options.MCPServerURL, timeout, userCred)
defer mcpClient.Close()
// 调用工具
result, err := mcpClient.CallTool(ctx, input.ToolName, input.Arguments)
if err != nil {
return nil, errors.Wrapf(err, "call tool %s", input.ToolName)
}
return jsonutils.Marshal(result), nil
}
// func (mcp *SMCPAgent) GetDetailsChatTest(
// ctx context.Context,
// userCred mcclient.TokenCredential,
// input api.LLMChatTestInput,
// ) (jsonutils.JSONObject, error) {
// llmClient := mcp.GetLLMClientDriver()
// if llmClient == nil {
// return nil, errors.Error("failed to get LLM client driver")
// }
// message := llmClient.NewUserMessage(input.Message)
// result, err := llmClient.Chat(ctx, mcp, []ILLMChatMessage{message}, nil)
// if err != nil {
// return nil, errors.Wrap(err, "chat with LLM")
// }
// return jsonutils.Marshal(result), nil
// }
func (mcp *SMCPAgent) GetDetailsChatStream(
ctx context.Context,
userCred mcclient.TokenCredential,
input api.LLMChatTestInput,
) (jsonutils.JSONObject, error) {
llmClient := mcp.GetLLMClientDriver()
if llmClient == nil {
return nil, errors.Error("failed to get LLM client driver")
}
appParams := appsrv.AppContextGetParams(ctx)
if appParams == nil {
return nil, errors.Error("failed to get app params")
}
w := appParams.Response
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
message := llmClient.NewUserMessage(input.Message)
err := llmClient.ChatStream(ctx, mcp, []ILLMChatMessage{message}, nil, func(chunk ILLMChatResponse) error {
content := chunk.GetContent()
if len(content) > 0 {
fmt.Fprintf(w, "%s", content)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
return nil
})
if err != nil {
fmt.Fprintf(w, "\nError: %v\n", err)
}
return nil, nil
}
func (mcp *SMCPAgent) GetDetailsRequest(
ctx context.Context,
userCred mcclient.TokenCredential,
input api.LLMMCPAgentRequestInput,
) (jsonutils.JSONObject, error) {
// 调用 ProcessMCPAgentRequest
answer, err := mcp.process(ctx, userCred, &input)
if err != nil {
return nil, errors.Wrap(err, "process MCP agent request")
}
// 返回结果
result := map[string]interface{}{
"answer": answer.Answer,
}
return jsonutils.Marshal(result), nil
}
// process 处理用户请求
func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCredential, req *api.LLMMCPAgentRequestInput) (*api.MCPAgentResponse, error) {
// 获取 MCP Server 的工具列表
mcpClient := utils.NewMCPClient(mcp.McpServer, 10*time.Minute, userCred)
defer mcpClient.Close()
mcpTools, err := mcpClient.ListTools(ctx)
if err != nil {
return nil, errors.Wrap(err, "list MCP tools")
}
log.Infof("Got %d tools from MCP Server", len(mcpTools))
// get llmClient
llmClient := mcp.GetLLMClientDriver()
if llmClient == nil {
return nil, errors.Error("failed to get LLM client driver")
}
tools := llmClient.ConvertMCPTools(mcpTools)
// 构建系统提示词
systemPrompt := buildSystemPrompt()
// 初始化消息历史,使用接口类型
messages := []ILLMChatMessage{
llmClient.NewSystemMessage(systemPrompt),
llmClient.NewUserMessage(req.Query),
}
// 记录工具调用
var toolCallRecords []api.MCPAgentToolCallRecord
// Agent 循环
for i := 0; i < api.MCPAgentMaxIterations; i++ {
log.Infof("Agent iteration %d", i+1)
// 调用 LLM 客户端,传入接口类型
resp, err := llmClient.Chat(ctx, mcp, messages, tools)
if err != nil {
return nil, errors.Wrap(err, "chat with LLM client")
}
// 检查是否有工具调用
if !resp.HasToolCalls() {
// 没有工具调用,返回最终答案
return &api.MCPAgentResponse{
Success: true,
Answer: resp.GetContent(),
ToolCalls: toolCallRecords,
}, nil
}
// 处理工具调用
toolCalls := resp.GetToolCalls()
log.Infof("Got %d tool calls from LLM", len(toolCalls))
// 添加助手消息(带工具调用),使用接口类型
messages = append(messages, llmClient.NewAssistantMessageWithToolCalls(toolCalls))
// 执行每个工具调用
for _, tc := range toolCalls {
fc := tc.GetFunction()
toolName := fc.GetName()
arguments := fc.GetArguments()
// 确保 arguments 不为 nil
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))
}
}
// 达到最大迭代次数
return &api.MCPAgentResponse{
Success: false,
Answer: "处理请求时达到最大迭代次数,请尝试简化您的问题。",
Error: "max iterations reached",
ToolCalls: toolCallRecords,
}, nil
}
// buildSystemPrompt 构建系统提示词
func buildSystemPrompt() string {
return api.MCP_AGENT_SYSTEM_PROMPT
}
+4
View File
@@ -26,6 +26,10 @@ type LLMOptions struct {
ModelSyncTaskWaitSecs int `help:"model sync task wait seconds" default:"30"`
StartTaskWorkerCount int `help:"start task worker count" default:"128"`
// 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"`
}
var (
+1
View File
@@ -41,6 +41,7 @@ func InitHandlers(app *appsrv.Application, isSlave bool) {
models.GetDifyManager(),
models.GetInstantModelManager(),
models.GetLLMInstantModelManager(),
models.GetMCPAgentManager(),
} {
db.RegisterModelManager(manager)
handler := db.NewModelHandler(manager)
+1
View File
@@ -11,6 +11,7 @@ import (
app_common "yunion.io/x/onecloud/pkg/cloudcommon/app"
"yunion.io/x/onecloud/pkg/cloudcommon/db"
common_options "yunion.io/x/onecloud/pkg/cloudcommon/options"
_ "yunion.io/x/onecloud/pkg/llm/drivers/llm_client"
_ "yunion.io/x/onecloud/pkg/llm/drivers/llm_container"
"yunion.io/x/onecloud/pkg/llm/models"
"yunion.io/x/onecloud/pkg/llm/options"
+437
View File
@@ -0,0 +1,437 @@
// Copyright 2019 Yunion
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package utils
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/mark3labs/mcp-go/mcp"
"yunion.io/x/jsonutils"
"yunion.io/x/log"
"yunion.io/x/pkg/errors"
"yunion.io/x/onecloud/pkg/mcclient"
"yunion.io/x/onecloud/pkg/mcclient/auth"
)
// mcpError represents the error object in a JSON-RPC response
type mcpError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// rawMCPResponse 用于处理 MCP 响应,支持延迟解析 Result
type rawMCPResponse struct {
JSONRPC string `json:"jsonrpc"`
ID mcp.RequestId `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *mcpError `json:"error,omitempty"`
}
// MCPClient 是 MCP Server 的客户端,通过 SSE 协议与 MCP Server 通信
type MCPClient struct {
serverURL string
client *http.Client
sessionURL string
sseBody io.ReadCloser
messageID int64
mu sync.Mutex
initialized bool
userCred mcclient.TokenCredential
pendingReqs map[int64]chan *rawMCPResponse
reqMu sync.Mutex
}
// NewMCPClient 创建一个新的 MCP 客户端
func NewMCPClient(serverURL string, timeout time.Duration, userCred mcclient.TokenCredential) *MCPClient {
return &MCPClient{
serverURL: strings.TrimSuffix(serverURL, "/"),
client: &http.Client{
Timeout: timeout,
},
userCred: userCred,
pendingReqs: make(map[int64]chan *rawMCPResponse),
}
}
// connectSSE 连接 SSE 端点并开始事件循环
func (c *MCPClient) connectSSE(ctx context.Context) error {
// 连接 SSE 端点获取 session URL
sseURL := c.serverURL + "/sse"
req, err := http.NewRequestWithContext(ctx, "GET", sseURL, nil)
if err != nil {
return errors.Wrap(err, "create SSE request")
}
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Cache-Control", "no-cache")
resp, err := c.client.Do(req)
if err != nil {
return errors.Wrap(err, "connect to SSE")
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return errors.Errorf("SSE connection failed with status %d: %s", resp.StatusCode, string(body))
}
c.sseBody = resp.Body
// Channel to signal session URL found
done := make(chan struct{})
var initErr error
// 读取 endpoint 事件获取 session URL
go func() {
reader := bufio.NewReader(c.sseBody)
foundSession := false
defer func() {
if !foundSession {
select {
case <-done:
default:
close(done)
}
}
}()
for {
line, err := reader.ReadString('\n')
if err != nil {
if !foundSession {
initErr = err
} else {
log.Warningf("SSE connection closed: %v", err)
}
return
}
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "data:") {
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if !foundSession {
if strings.Contains(data, "/message") {
// 解析 session URL
c.sessionURL = c.serverURL + data
log.Infof("MCP Client initialized with session URL: %s", c.sessionURL)
foundSession = true
close(done)
}
} else {
// 尝试解析为 JSON-RPC 响应
var resp rawMCPResponse
if err := json.Unmarshal([]byte(data), &resp); err == nil && resp.JSONRPC == mcp.JSONRPC_VERSION {
// 提取 ID
var reqID int64
if idVal, ok := resp.ID.Value().(int64); ok {
reqID = idVal
} else if idVal, ok := resp.ID.Value().(float64); ok {
reqID = int64(idVal)
} else {
// 可能是通知或 ID 类型不匹配,忽略
continue
}
c.reqMu.Lock()
ch, ok := c.pendingReqs[reqID]
if ok {
delete(c.pendingReqs, reqID)
}
c.reqMu.Unlock()
if ok {
select {
case ch <- &resp:
default:
log.Warningf("response channel blocked for request %d", reqID)
}
}
}
}
}
}
}()
// Wait for session URL
select {
case <-done:
if initErr != nil {
c.sseBody.Close()
return errors.Wrap(initErr, "read SSE event")
}
case <-time.After(10 * time.Second):
c.sseBody.Close()
return errors.Error("timeout waiting for session URL")
case <-ctx.Done():
c.sseBody.Close()
return ctx.Err()
}
return nil
}
// Initialize 初始化 MCP 客户端连接
func (c *MCPClient) Initialize(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.initialized {
return nil
}
if err := c.connectSSE(ctx); err != nil {
return err
}
// 发送初始化请求
initParams := mcp.InitializeParams{
ProtocolVersion: "2024-11-05",
Capabilities: mcp.ClientCapabilities{},
ClientInfo: mcp.Implementation{
Name: "cloudpods-mcp-agent",
Version: "1.0.0",
},
}
initReq := mcp.JSONRPCRequest{
JSONRPC: mcp.JSONRPC_VERSION,
ID: mcp.NewRequestId(c.nextMessageID()),
Params: initParams,
}
initReq.Method = string(mcp.MethodInitialize)
_, err := c.sendRequest(ctx, initReq)
if err != nil {
c.sseBody.Close()
return errors.Wrap(err, "send initialize request")
}
// 发送 initialized 通知
notifyReq := mcp.JSONRPCRequest{
JSONRPC: mcp.JSONRPC_VERSION,
}
notifyReq.Method = "notifications/initialized"
_, err = c.sendRequest(ctx, notifyReq)
if err != nil {
log.Warningf("send initialized notification failed: %v", err)
}
c.initialized = true
return nil
}
// nextMessageID 生成下一个消息 ID
func (c *MCPClient) nextMessageID() int64 {
return atomic.AddInt64(&c.messageID, 1)
}
// sendRequest 发送 JSON-RPC 请求
func (c *MCPClient) sendRequest(ctx context.Context, req mcp.JSONRPCRequest) (*rawMCPResponse, error) {
var respChan chan *rawMCPResponse
var reqID int64
var hasID bool
if !req.ID.IsNil() {
if idVal, ok := req.ID.Value().(int64); ok {
reqID = idVal
hasID = true
}
}
if hasID {
respChan = make(chan *rawMCPResponse, 1)
c.reqMu.Lock()
c.pendingReqs[reqID] = respChan
c.reqMu.Unlock()
// 确保在出错返回时清理 pendingReqs
defer func() {
c.reqMu.Lock()
delete(c.pendingReqs, reqID)
c.reqMu.Unlock()
}()
}
reqBody := jsonutils.Marshal(req)
log.Infof("MCP request: %s", reqBody.String())
cli := auth.Client()
if cli == nil {
cli = mcclient.NewClient("", 0, false, true, "", "")
}
cred := c.userCred
if cred == nil {
log.Warningf("userCred is nil in sendRequest, creating empty token")
cred = &mcclient.SSimpleToken{}
}
s := cli.NewSession(ctx, "", "", "", cred)
s.SetServiceUrl("mcp", c.sessionURL)
_, respBody, err := s.JSONRequest("mcp", "", "POST", "", nil, reqBody)
if err != nil {
return nil, errors.Wrap(err, "send request")
}
// 对于通知请求,可能没有响应体
if !hasID {
return nil, nil
}
// 如果有响应体,直接解析
if respBody != nil {
log.Debugf("MCP response (HTTP): %s", respBody.String())
var mcpResp rawMCPResponse
if err := respBody.Unmarshal(&mcpResp); err != nil {
return nil, errors.Wrap(err, "decode response")
}
if mcpResp.Error != nil {
return nil, errors.Errorf("MCP error %d: %s", mcpResp.Error.Code, mcpResp.Error.Message)
}
// 成功收到 HTTP 响应,从 pending 中移除(defer 会做,但我们可以提前返回)
return &mcpResp, nil
}
// 如果响应为空,等待 SSE 推送
select {
case mcpResp := <-respChan:
log.Debugf("MCP response (SSE): ID=%v", mcpResp.ID)
if mcpResp.Error != nil {
return nil, errors.Errorf("MCP error %d: %s", mcpResp.Error.Code, mcpResp.Error.Message)
}
return mcpResp, nil
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(30 * time.Second):
return nil, errors.Error("timeout waiting for SSE response")
}
}
// ListTools 获取可用工具列表
func (c *MCPClient) ListTools(ctx context.Context) ([]mcp.Tool, error) {
if !c.initialized {
if err := c.Initialize(ctx); err != nil {
return nil, errors.Wrap(err, "initialize client")
}
}
req := mcp.JSONRPCRequest{
JSONRPC: mcp.JSONRPC_VERSION,
ID: mcp.NewRequestId(c.nextMessageID()),
}
req.Method = string(mcp.MethodToolsList)
resp, err := c.sendRequest(ctx, req)
if err != nil {
return nil, errors.Wrap(err, "send tools/list request")
}
if resp == nil {
return nil, errors.Error("empty response for tools/list")
}
var result mcp.ListToolsResult
if err := json.Unmarshal(resp.Result, &result); err != nil {
return nil, errors.Wrap(err, "decode tools list result")
}
return result.Tools, nil
}
// CallTool 调用工具
func (c *MCPClient) CallTool(ctx context.Context, toolName string, arguments map[string]interface{}) (*mcp.CallToolResult, error) {
if !c.initialized {
if err := c.Initialize(ctx); err != nil {
return nil, errors.Wrap(err, "initialize client")
}
}
params := mcp.CallToolParams{
Name: toolName,
Arguments: arguments,
}
req := mcp.JSONRPCRequest{
JSONRPC: mcp.JSONRPC_VERSION,
ID: mcp.NewRequestId(c.nextMessageID()),
Params: params,
}
req.Method = string(mcp.MethodToolsCall)
resp, err := c.sendRequest(ctx, req)
if err != nil {
return nil, errors.Wrap(err, "send tools/call request")
}
if resp == nil {
return nil, errors.Error("empty response for tools/call")
}
var result mcp.CallToolResult
if err := json.Unmarshal(resp.Result, &result); err != nil {
return nil, errors.Wrap(err, "decode tool call result")
}
return &result, nil
}
// GetToolResultText 从工具调用结果中提取文本
func GetToolResultText(r *mcp.CallToolResult) string {
var texts []string
for _, content := range r.Content {
if textContent, ok := content.(mcp.TextContent); ok {
texts = append(texts, textContent.Text)
}
}
return strings.Join(texts, "\n")
}
// FormatToolResult 格式化工具调用结果
func FormatToolResult(toolName string, result *mcp.CallToolResult, err error) string {
if err != nil {
return fmt.Sprintf("工具 %s 调用失败: %v", toolName, err)
}
if result.IsError {
return fmt.Sprintf("工具 %s 返回错误: %s", toolName, GetToolResultText(result))
}
return GetToolResultText(result)
}
// Close 关闭客户端连接
func (c *MCPClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.initialized = false
c.sessionURL = ""
if c.sseBody != nil {
c.sseBody.Close()
c.sseBody = nil
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
package llm
import (
"yunion.io/x/onecloud/pkg/mcclient/modulebase"
"yunion.io/x/onecloud/pkg/mcclient/modules"
)
type MCPAgentManager struct {
modulebase.ResourceManager
}
var (
MCPAgent MCPAgentManager
)
func init() {
MCPAgent = MCPAgentManager{
ResourceManager: modules.NewLLMManager("mcp_agent", "mcp_agents",
[]string{},
[]string{},
),
}
modules.Register(&MCPAgent)
}
+167
View File
@@ -0,0 +1,167 @@
package llm
import (
"strings"
"yunion.io/x/jsonutils"
"yunion.io/x/onecloud/pkg/apis"
api "yunion.io/x/onecloud/pkg/apis/llm"
"yunion.io/x/onecloud/pkg/mcclient/options"
)
type MCPAgentListOptions struct {
options.BaseListOptions
LLMDriver string `json:"llm_driver" help:"filter by llm driver (ollama or openai)"`
}
func (o *MCPAgentListOptions) Params() (jsonutils.JSONObject, error) {
return options.ListStructToParams(o)
}
type MCPAgentShowOptions struct {
options.BaseShowOptions
}
func (o *MCPAgentShowOptions) Params() (jsonutils.JSONObject, error) {
return options.StructToParams(o)
}
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"`
}
func (o *MCPAgentCreateOptions) Params() (jsonutils.JSONObject, error) {
return jsonutils.Marshal(o), nil
}
type MCPAgentUpdateOptions struct {
apis.SharableVirtualResourceCreateInput
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"`
}
func (o *MCPAgentUpdateOptions) GetId() string {
return o.ID
}
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))
}
if o.LlmDriver != nil && len(*o.LlmDriver) > 0 {
params.Set("llm_driver", jsonutils.NewString(*o.LlmDriver))
}
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))
}
// 添加基础字段
baseParams, err := options.StructToParams(&o.SharableVirtualResourceCreateInput)
if err != nil {
return nil, err
}
if baseParams != nil {
params.Update(baseParams)
}
return params, nil
}
type MCPAgentDeleteOptions struct {
options.BaseIdOptions
}
func (o *MCPAgentDeleteOptions) GetId() string {
return o.ID
}
func (o *MCPAgentDeleteOptions) Params() (jsonutils.JSONObject, error) {
return options.StructToParams(o)
}
type MCPAgentIdOptions struct {
ID string `help:"mcp agent id" json:"-"`
}
func (opts *MCPAgentIdOptions) GetId() string {
return opts.ID
}
func (opts *MCPAgentIdOptions) Params() (jsonutils.JSONObject, error) {
return jsonutils.Marshal(opts), nil
}
type MCPAgentToolRequestOptions struct {
MCPAgentIdOptions
TOOL_NAME string `help:"tool name" json:"tool_name"`
Argument []string `help:"tool arguments, e.g. key=value" json:"argument"`
}
func (opts *MCPAgentToolRequestOptions) Params() (jsonutils.JSONObject, error) {
input := api.LLMToolRequestInput{
ToolName: opts.TOOL_NAME,
Arguments: make(map[string]interface{}),
}
for _, arg := range opts.Argument {
idx := strings.Index(arg, "=")
if idx > 0 {
key := arg[:idx]
val := arg[idx+1:]
input.Arguments[key] = val
}
}
return jsonutils.Marshal(input), nil
}
type MCPAgentChatTestOptions struct {
MCPAgentIdOptions
Message string `help:"test message to send to LLM" json:"message"`
}
func (opts *MCPAgentChatTestOptions) Params() (jsonutils.JSONObject, error) {
input := api.LLMChatTestInput{
Message: opts.Message,
}
return jsonutils.Marshal(input), nil
}
type MCPAgentMCPAgentRequestOptions struct {
MCPAgentIdOptions
Query string `help:"query to send to MCP agent" json:"query"`
}
func (opts *MCPAgentMCPAgentRequestOptions) Params() (jsonutils.JSONObject, error) {
input := api.LLMMCPAgentRequestInput{
Query: opts.Query,
}
return jsonutils.Marshal(input), nil
}