mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-17 23:33:49 +08:00
fix(plugins): expose saved plugin config
This commit is contained in:
@@ -183,6 +183,7 @@ The native plugin management endpoints remain:
|
||||
```text
|
||||
GET /v0/management/plugins
|
||||
PATCH /v0/management/plugins/{pluginID}/enabled
|
||||
GET /v0/management/plugins/{pluginID}/config
|
||||
PUT /v0/management/plugins/{pluginID}/config
|
||||
PATCH /v0/management/plugins/{pluginID}/config
|
||||
```
|
||||
|
||||
@@ -149,6 +149,50 @@ func (h *Handler) ListPlugins(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GetPluginConfig returns the preserved plugins.configs.<id> object as JSON.
|
||||
func (h *Handler) GetPluginConfig(c *gin.Context) {
|
||||
id, okID := pluginIDFromRequest(c)
|
||||
if !okID {
|
||||
return
|
||||
}
|
||||
if h == nil || h.cfg == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found"})
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
item, configured := h.cfg.Plugins.Configs[id]
|
||||
pluginsDir := normalizedPluginsDir(h.cfg.Plugins.Dir)
|
||||
host := h.pluginHost
|
||||
h.mu.Unlock()
|
||||
|
||||
if configured {
|
||||
body, errBody := pluginConfigJSONObject(item)
|
||||
if errBody != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_config_encode_failed", "message": errBody.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, body)
|
||||
return
|
||||
}
|
||||
|
||||
if pluginRegistered(host, id) {
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
return
|
||||
}
|
||||
discovered, errDiscover := pluginDiscovered(pluginsDir, id)
|
||||
if errDiscover != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "plugin_discovery_failed", "message": errDiscover.Error()})
|
||||
return
|
||||
}
|
||||
if discovered {
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "plugin_not_found", "message": "plugin not found"})
|
||||
}
|
||||
|
||||
// PatchPluginEnabled updates plugins.configs.<id>.enabled without touching plugins.enabled.
|
||||
func (h *Handler) PatchPluginEnabled(c *gin.Context) {
|
||||
id, okID := pluginIDFromRequest(c)
|
||||
@@ -263,6 +307,31 @@ func pluginInstanceEnabled(item config.PluginInstanceConfig) bool {
|
||||
return *item.Enabled
|
||||
}
|
||||
|
||||
func pluginRegistered(host *pluginhost.Host, id string) bool {
|
||||
if host == nil {
|
||||
return false
|
||||
}
|
||||
for _, info := range host.RegisteredPlugins() {
|
||||
if info.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pluginDiscovered(pluginsDir string, id string) (bool, error) {
|
||||
files, errDiscover := pluginhost.DiscoverPluginFiles(pluginsDir)
|
||||
if errDiscover != nil {
|
||||
return false, errDiscover
|
||||
}
|
||||
for _, file := range files {
|
||||
if file.ID == id {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func pluginConfigFields(fields []pluginapi.ConfigField) []pluginConfigFieldInfo {
|
||||
out := make([]pluginConfigFieldInfo, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
@@ -344,6 +413,18 @@ func pluginConfigNode(item config.PluginInstanceConfig) *yaml.Node {
|
||||
return node
|
||||
}
|
||||
|
||||
func pluginConfigJSONObject(item config.PluginInstanceConfig) (map[string]any, error) {
|
||||
value, errValue := yamlNodeToJSONValue(pluginConfigNode(item))
|
||||
if errValue != nil {
|
||||
return nil, errValue
|
||||
}
|
||||
body, ok := value.(map[string]any)
|
||||
if !ok || body == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func pluginInstanceConfigFromNode(node *yaml.Node) (config.PluginInstanceConfig, error) {
|
||||
if node == nil {
|
||||
node = emptyYAMLMappingNode()
|
||||
@@ -407,6 +488,52 @@ func yamlNodeFromJSONValue(value any) (*yaml.Node, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func yamlNodeToJSONValue(node *yaml.Node) (any, error) {
|
||||
if node == nil {
|
||||
return nil, nil
|
||||
}
|
||||
switch node.Kind {
|
||||
case yaml.MappingNode:
|
||||
out := make(map[string]any, len(node.Content)/2)
|
||||
for index := 0; index+1 < len(node.Content); index += 2 {
|
||||
key := node.Content[index]
|
||||
value := node.Content[index+1]
|
||||
if key == nil {
|
||||
continue
|
||||
}
|
||||
child, errChild := yamlNodeToJSONValue(value)
|
||||
if errChild != nil {
|
||||
return nil, fmt.Errorf("%s: %w", key.Value, errChild)
|
||||
}
|
||||
out[key.Value] = child
|
||||
}
|
||||
return out, nil
|
||||
case yaml.SequenceNode:
|
||||
out := make([]any, 0, len(node.Content))
|
||||
for _, childNode := range node.Content {
|
||||
child, errChild := yamlNodeToJSONValue(childNode)
|
||||
if errChild != nil {
|
||||
return nil, errChild
|
||||
}
|
||||
out = append(out, child)
|
||||
}
|
||||
return out, nil
|
||||
case yaml.ScalarNode:
|
||||
if node.Tag == "!!str" || node.Tag == "" {
|
||||
return node.Value, nil
|
||||
}
|
||||
var value any
|
||||
if errDecode := node.Decode(&value); errDecode != nil {
|
||||
return nil, errDecode
|
||||
}
|
||||
return value, nil
|
||||
case yaml.AliasNode:
|
||||
return yamlNodeToJSONValue(node.Alias)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported YAML node kind %d", node.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func emptyYAMLMappingNode() *yaml.Node {
|
||||
return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,117 @@ func TestListPluginsIncludesScannedAndConfiguredPlugins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPluginConfigReturnsPreservedRawConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample": pluginConfigFromYAML(t, `
|
||||
enabled: false
|
||||
priority: 7
|
||||
mode: safe
|
||||
allowed_models:
|
||||
- gemini-2.5-pro
|
||||
- claude-sonnet-4
|
||||
options:
|
||||
retries: 2
|
||||
strict: true
|
||||
`),
|
||||
},
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: "sample"}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugins/sample/config", nil)
|
||||
|
||||
h.GetPluginConfig(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Priority int `json:"priority"`
|
||||
Mode string `json:"mode"`
|
||||
AllowedModels []string `json:"allowed_models"`
|
||||
Options map[string]any `json:"options"`
|
||||
}
|
||||
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
|
||||
t.Fatalf("decode response: %v; body=%s", errDecode, rec.Body.String())
|
||||
}
|
||||
if body.Enabled || body.Priority != 7 || body.Mode != "safe" {
|
||||
t.Fatalf("base fields = enabled %v priority %d mode %q, want false 7 safe", body.Enabled, body.Priority, body.Mode)
|
||||
}
|
||||
if len(body.AllowedModels) != 2 || body.AllowedModels[0] != "gemini-2.5-pro" || body.AllowedModels[1] != "claude-sonnet-4" {
|
||||
t.Fatalf("allowed_models = %#v", body.AllowedModels)
|
||||
}
|
||||
if body.Options["retries"] != float64(2) || body.Options["strict"] != true {
|
||||
t.Fatalf("options = %#v", body.Options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPluginConfigReturnsEmptyObjectForKnownUnconfiguredPlugin(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
pluginsDir := writeManagementPluginFile(t, "scanned")
|
||||
h := &Handler{
|
||||
cfg: &config.Config{
|
||||
Plugins: config.PluginsConfig{
|
||||
Dir: pluginsDir,
|
||||
},
|
||||
},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: "scanned"}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugins/scanned/config", nil)
|
||||
|
||||
h.GetPluginConfig(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
|
||||
t.Fatalf("decode response: %v; body=%s", errDecode, rec.Body.String())
|
||||
}
|
||||
if len(body) != 0 {
|
||||
t.Fatalf("body = %#v, want empty object", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPluginConfigReturnsNotFoundForUnknownPlugin(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &Handler{
|
||||
cfg: &config.Config{},
|
||||
configFilePath: writeTestConfigFile(t),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: "missing"}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v0/management/plugins/missing/config", nil)
|
||||
|
||||
h.GetPluginConfig(c)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchPluginEnabledUpdatesOnlyPluginConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -615,6 +615,7 @@ func (s *Server) registerManagementRoutes() {
|
||||
mgmt.GET("/plugin-store", s.mgmt.ListPluginStore)
|
||||
mgmt.POST("/plugin-store/:id/install", s.mgmt.InstallPluginFromStore)
|
||||
mgmt.PATCH("/plugins/:id/enabled", s.mgmt.PatchPluginEnabled)
|
||||
mgmt.GET("/plugins/:id/config", s.mgmt.GetPluginConfig)
|
||||
mgmt.PUT("/plugins/:id/config", s.mgmt.PutPluginConfig)
|
||||
mgmt.PATCH("/plugins/:id/config", s.mgmt.PatchPluginConfig)
|
||||
|
||||
|
||||
@@ -182,6 +182,10 @@ func TestManagementPluginsRouteRegistered(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "test-management-key")
|
||||
|
||||
server := newTestServer(t)
|
||||
enabled := true
|
||||
server.cfg.Plugins.Configs = map[string]proxyconfig.PluginInstanceConfig{
|
||||
"sample": {Enabled: &enabled, Priority: 4},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v0/management/plugins", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-management-key")
|
||||
@@ -202,6 +206,24 @@ func TestManagementPluginsRouteRegistered(t *testing.T) {
|
||||
if payload.Plugins == nil {
|
||||
t.Fatalf("plugins field = nil, want array; body=%s", rr.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/v0/management/plugins/sample/config", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-management-key")
|
||||
rr = httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("config status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
var configPayload struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
if errUnmarshal := json.Unmarshal(rr.Body.Bytes(), &configPayload); errUnmarshal != nil {
|
||||
t.Fatalf("unmarshal config response: %v body=%s", errUnmarshal, rr.Body.String())
|
||||
}
|
||||
if !configPayload.Enabled || configPayload.Priority != 4 {
|
||||
t.Fatalf("plugin config = %#v, want enabled true priority 4", configPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeEnabledHidesManagementEndpointsAndControlPanel(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user