diff --git a/cmd/server_main.go b/cmd/server_main.go index f1b656d4d..9e60b6776 100644 --- a/cmd/server_main.go +++ b/cmd/server_main.go @@ -219,6 +219,7 @@ func startServer(config *server.Config) { tenantService, &handler.SearchbotRealLLM{Svc: modelProviderService}, ) + pluginHandler := handler.NewPluginHandler(service.NewPluginService()) // Dify retrieval handler docDAO := dao.NewDocumentDAO() @@ -233,7 +234,7 @@ func startServer(config *server.Config) { ) // Initialize router - r := router.NewRouter(authHandler, userHandler, tenantHandler, documentHandler, datasetsHandler, systemHandler, knowledgebaseHandler, chunkHandler, llmHandler, chatHandler, chatSessionHandler, connectorHandler, searchHandler, fileHandler, memoryHandler, mcpHandler, skillSearchHandler, providerHandler, agentHandler, relatedQuestionsHandler, difyRetrievalHandler) + r := router.NewRouter(authHandler, userHandler, tenantHandler, documentHandler, datasetsHandler, systemHandler, knowledgebaseHandler, chunkHandler, llmHandler, chatHandler, chatSessionHandler, connectorHandler, searchHandler, fileHandler, memoryHandler, mcpHandler, skillSearchHandler, providerHandler, agentHandler, relatedQuestionsHandler, difyRetrievalHandler, pluginHandler) // Create Gin engine ginEngine := gin.New() diff --git a/internal/handler/plugin.go b/internal/handler/plugin.go new file mode 100644 index 000000000..191db75b9 --- /dev/null +++ b/internal/handler/plugin.go @@ -0,0 +1,61 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// 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 handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "ragflow/internal/common" + "ragflow/internal/service" +) + +// PluginHandler serves the /plugin/* HTTP routes. +type PluginHandler struct { + pluginService *service.PluginService +} + +// NewPluginHandler creates a plugin handler. +func NewPluginHandler(pluginService *service.PluginService) *PluginHandler { + return &PluginHandler{ + pluginService: pluginService, + } +} + +// ListLLMTools handles GET /v1/plugin/tools. +// +// @Summary List LLM tool plugins +// @Description Return the metadata of every embedded LLM tool plugin. Matches +// @Description the response of the Python GET /v1/plugin/tools endpoint. +// @Tags plugin +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {object} map[string]interface{} +// @Router /v1/plugin/tools [get] +func (h *PluginHandler) ListLLMTools(c *gin.Context) { + if _, errorCode, errorMessage := GetUser(c); errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "message": "success", + "data": h.pluginService.ListLLMTools(), + }) +} diff --git a/internal/handler/plugin_test.go b/internal/handler/plugin_test.go new file mode 100644 index 000000000..f29064bd8 --- /dev/null +++ b/internal/handler/plugin_test.go @@ -0,0 +1,173 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// 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 handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "ragflow/internal/common" + "ragflow/internal/entity" + "ragflow/internal/service" +) + +func newPluginRouter(authenticate bool) (*gin.Engine, *PluginHandler) { + gin.SetMode(gin.TestMode) + h := NewPluginHandler(service.NewPluginService()) + r := gin.New() + r.GET("/api/v1/plugin/tools", func(c *gin.Context) { + if authenticate { + c.Set("user", &entity.User{ID: "tenant-1"}) + } + h.ListLLMTools(c) + }) + return r, h +} + +func TestPluginHandlerListLLMToolsReturnsEmbeddedMetadata(t *testing.T) { + r, _ := newPluginRouter(true) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/plugin/tools", nil) + r.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String()) + } + + var body struct { + Code int `json:"code"` + Message string `json:"message"` + Data []service.LLMToolMetadata `json:"data"` + } + if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v body=%s", err, resp.Body.String()) + } + if body.Code != int(common.CodeSuccess) { + t.Fatalf("code=%d want=%d body=%s", body.Code, common.CodeSuccess, resp.Body.String()) + } + if body.Message != "success" { + t.Errorf("message=%q want=%q", body.Message, "success") + } + if len(body.Data) == 0 { + t.Fatalf("data should contain at least one embedded plugin, got 0") + } + + // Verify bad_calculator is present with the exact metadata shape the Python + // endpoint returns, so existing clients can swap backends transparently. + var bad *service.LLMToolMetadata + for i := range body.Data { + if body.Data[i].Name == "bad_calculator" { + bad = &body.Data[i] + break + } + } + if bad == nil { + t.Fatalf("bad_calculator missing from data=%+v", body.Data) + } + if bad.DisplayName != "$t:bad_calculator.name" { + t.Errorf("displayName=%q want %q", bad.DisplayName, "$t:bad_calculator.name") + } + if bad.Description == "" { + t.Errorf("description must be non-empty") + } + for _, k := range []string{"a", "b"} { + p, ok := bad.Parameters[k] + if !ok { + t.Errorf("parameter %q missing", k) + continue + } + if p.Type != "number" { + t.Errorf("param %q type=%q want number", k, p.Type) + } + if !p.Required { + t.Errorf("param %q must be required", k) + } + } +} + +func TestPluginHandlerListLLMToolsResponseFieldNamesMatchPython(t *testing.T) { + // Defensive check: the raw JSON keys (not Go field names) must match the + // camelCase keys the Python endpoint emits. Snake_case here would break + // any frontend that already binds to the Python contract. + r, _ := newPluginRouter(true) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/plugin/tools", nil) + r.ServeHTTP(resp, req) + + var envelope struct { + Data []map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(resp.Body.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(envelope.Data) == 0 { + t.Fatalf("data empty") + } + tool := envelope.Data[0] + for _, key := range []string{"name", "displayName", "description", "displayDescription", "parameters"} { + if _, ok := tool[key]; !ok { + t.Errorf("missing key %q in tool metadata, got keys=%v", key, mapKeys(tool)) + } + } + params, ok := tool["parameters"].(map[string]interface{}) + if !ok || len(params) == 0 { + t.Fatalf("parameters is not a non-empty object: %v", tool["parameters"]) + } + for paramName, raw := range params { + paramObj, ok := raw.(map[string]interface{}) + if !ok { + t.Fatalf("parameter %q is not an object", paramName) + } + for _, key := range []string{"type", "description", "displayDescription", "required"} { + if _, ok := paramObj[key]; !ok { + t.Errorf("parameter %q missing key %q (keys=%v)", paramName, key, mapKeys(paramObj)) + } + } + } +} + +func TestPluginHandlerListLLMToolsRejectsUnauthenticated(t *testing.T) { + r, _ := newPluginRouter(false) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/plugin/tools", nil) + r.ServeHTTP(resp, req) + + // jsonError encodes the error code into the JSON body; HTTP status is + // still 200 in this codebase's response style, so check the body code. + var body map[string]interface{} + if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v body=%s", err, resp.Body.String()) + } + if code, _ := body["code"].(float64); int(code) == int(common.CodeSuccess) { + t.Errorf("expected non-success code for unauthenticated request, got body=%v", body) + } +} + +func mapKeys(m map[string]interface{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/internal/router/router.go b/internal/router/router.go index f8ff3d23a..1f00b1d64 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -43,7 +43,8 @@ type Router struct { providerHandler *handler.ProviderHandler agentHandler *handler.AgentHandler relatedQuestionsHandler *handler.SearchbotHandler - difyRetrievalHandler *handler.DifyRetrievalHandler + difyRetrievalHandler *handler.DifyRetrievalHandler + pluginHandler *handler.PluginHandler } // NewRouter create router @@ -68,7 +69,8 @@ func NewRouter( providerHandler *handler.ProviderHandler, agentHandler *handler.AgentHandler, relatedQuestionsHandler *handler.SearchbotHandler, - difyRetrievalHandler *handler.DifyRetrievalHandler, + difyRetrievalHandler *handler.DifyRetrievalHandler, + pluginHandler *handler.PluginHandler, ) *Router { return &Router{ authHandler: authHandler, @@ -91,7 +93,8 @@ func NewRouter( providerHandler: providerHandler, agentHandler: agentHandler, relatedQuestionsHandler: relatedQuestionsHandler, - difyRetrievalHandler: difyRetrievalHandler, + difyRetrievalHandler: difyRetrievalHandler, + pluginHandler: pluginHandler, } } @@ -379,6 +382,12 @@ func (r *Router) Setup(engine *gin.Engine) { agents.PUT("/:agent_id/tags", r.agentHandler.UpdateAgentTags) } + // Plugin routes + plugin := v1.Group("/plugin") + { + plugin.GET("/tools", r.pluginHandler.ListLLMTools) + } + connector := v1.Group("/connectors") { connector.GET("/", r.connectorHandler.ListConnectors) diff --git a/internal/service/plugin.go b/internal/service/plugin.go new file mode 100644 index 000000000..c7fc33d1e --- /dev/null +++ b/internal/service/plugin.go @@ -0,0 +1,89 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// 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 service + +// PluginService exposes metadata for embedded LLM tool plugins. +// +// The Python service discovers plugins dynamically from +// agent/plugin/embedded_plugins/llm_tools via pluginlib. The Go server has no +// runtime Python loader, so the embedded set is mirrored as a static table. +// To add a plugin here, append to embeddedLLMTools below. +type PluginService struct{} + +// NewPluginService creates a new plugin service. +func NewPluginService() *PluginService { + return &PluginService{} +} + +// LLMToolParameter mirrors agent.plugin.llm_tool_plugin.LLMToolParameter. +type LLMToolParameter struct { + Type string `json:"type"` + Description string `json:"description"` + DisplayDescription string `json:"displayDescription"` + Required bool `json:"required"` +} + +// LLMToolMetadata mirrors agent.plugin.llm_tool_plugin.LLMToolMetadata. +type LLMToolMetadata struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + DisplayDescription string `json:"displayDescription"` + Parameters map[string]LLMToolParameter `json:"parameters"` +} + +var embeddedLLMTools = []LLMToolMetadata{ + { + Name: "bad_calculator", + DisplayName: "$t:bad_calculator.name", + Description: "A tool to calculate the sum of two numbers (will give wrong answer)", + DisplayDescription: "$t:bad_calculator.description", + Parameters: map[string]LLMToolParameter{ + "a": { + Type: "number", + Description: "The first number", + DisplayDescription: "$t:bad_calculator.params.a", + Required: true, + }, + "b": { + Type: "number", + Description: "The second number", + DisplayDescription: "$t:bad_calculator.params.b", + Required: true, + }, + }, + }, +} + +// ListLLMTools returns the metadata of every embedded LLM tool plugin in the +// same order, shape and field names as the Python /plugin/tools endpoint. +// +// The returned slice and its nested Parameters maps are fresh copies — callers +// may mutate the result without affecting other requests or the package-level +// embeddedLLMTools table. +func (s *PluginService) ListLLMTools() []LLMToolMetadata { + out := make([]LLMToolMetadata, len(embeddedLLMTools)) + copy(out, embeddedLLMTools) + for i := range out { + params := make(map[string]LLMToolParameter, len(out[i].Parameters)) + for k, v := range out[i].Parameters { + params[k] = v + } + out[i].Parameters = params + } + return out +}