mirror of
https://github.com/yunionio/cloudpods.git
synced 2026-08-29 03:51:54 +08:00
* feature(llm): add mcp-agent * feature(llm): support openai in llm_client * feature(llm): chat-stream demo
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
package llm_client
|
||||
@@ -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, ¶ms)
|
||||
} else {
|
||||
schemaBytes, _ := json.Marshal(t.InputSchema)
|
||||
_ = json.Unmarshal(schemaBytes, ¶ms)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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, ¶ms)
|
||||
} else {
|
||||
schemaBytes, _ := json.Marshal(t.InputSchema)
|
||||
_ = json.Unmarshal(schemaBytes, ¶ms)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user