mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-23 17:03:54 +08:00
- Implemented command-line flag registration and execution for plugins with priority-based conflict resolution. - Enabled plugin-owned command-line flag execution and persistence of plugin-auth data. - Added new `Host` methods to support command-line capabilities, including flag normalization, validation, and execution state management. - Introduced unit tests to ensure coverage for command-line plugin functionality, including auth data persistence. - Updated configs to normalize plugins during initialization.
421 lines
15 KiB
Go
421 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
|
)
|
|
|
|
// Register is called once when the host first loads this .so file.
|
|
func Register(configYAML []byte) pluginapi.Plugin {
|
|
return buildPlugin(configYAML)
|
|
}
|
|
|
|
// Reconfigure is called on config hot reload while this plugin remains enabled.
|
|
func Reconfigure(configYAML []byte) pluginapi.Plugin {
|
|
return buildPlugin(configYAML)
|
|
}
|
|
|
|
func buildPlugin(configYAML []byte) pluginapi.Plugin {
|
|
example := &examplePlugin{configYAML: append([]byte(nil), configYAML...)}
|
|
return pluginapi.Plugin{
|
|
Metadata: pluginapi.Metadata{
|
|
Name: "example",
|
|
Version: "0.1.0",
|
|
Author: "router-for-me",
|
|
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
|
|
Logo: "https://raw.githubusercontent.com/router-for-me/CLIProxyAPI/main/docs/logo.png",
|
|
ConfigFields: []pluginapi.ConfigField{
|
|
{
|
|
Name: "config1",
|
|
Type: pluginapi.ConfigFieldTypeBoolean,
|
|
Description: "Enables the example boolean option.",
|
|
},
|
|
{
|
|
Name: "config2",
|
|
Type: pluginapi.ConfigFieldTypeString,
|
|
Description: "Stores the example string option.",
|
|
},
|
|
{
|
|
Name: "config3",
|
|
Type: pluginapi.ConfigFieldTypeInteger,
|
|
Description: "Stores the example integer option.",
|
|
},
|
|
{
|
|
Name: "mode",
|
|
Type: pluginapi.ConfigFieldTypeEnum,
|
|
EnumValues: []string{"safe", "fast"},
|
|
Description: "Selects the example execution mode.",
|
|
},
|
|
},
|
|
},
|
|
Capabilities: pluginapi.Capabilities{
|
|
ModelProvider: example,
|
|
AuthProvider: example,
|
|
FrontendAuthProvider: example,
|
|
Executor: example,
|
|
ExecutorModelScope: pluginapi.ExecutorModelScopeBoth,
|
|
RequestTranslator: example,
|
|
RequestNormalizer: example,
|
|
ResponseTranslator: example,
|
|
ResponseBeforeTranslator: example,
|
|
ResponseAfterTranslator: example,
|
|
ThinkingApplier: example,
|
|
UsagePlugin: example,
|
|
CommandLinePlugin: example,
|
|
ManagementAPI: example,
|
|
},
|
|
}
|
|
}
|
|
|
|
type examplePlugin struct {
|
|
configYAML []byte
|
|
mu sync.Mutex
|
|
usageCount int64
|
|
}
|
|
|
|
var _ pluginapi.AuthProvider = (*examplePlugin)(nil)
|
|
var _ pluginapi.ModelProvider = (*examplePlugin)(nil)
|
|
var _ pluginapi.ProviderExecutor = (*examplePlugin)(nil)
|
|
var _ pluginapi.ThinkingApplier = (*examplePlugin)(nil)
|
|
|
|
// Native logic always has higher priority than plugin logic.
|
|
// Native model registration always runs before plugin model discovery.
|
|
// Executor-backed plugin models can be static, OAuth auth-bound, or both.
|
|
func (p *examplePlugin) StaticModels(context.Context, pluginapi.StaticModelRequest) (pluginapi.ModelResponse, error) {
|
|
return pluginapi.ModelResponse{
|
|
Provider: "plugin-example",
|
|
Models: []pluginapi.ModelInfo{{
|
|
ID: "plugin-example-model",
|
|
Object: "model",
|
|
OwnedBy: "plugin-example",
|
|
Type: "chat",
|
|
DisplayName: "Plugin Example Model",
|
|
Name: "plugin-example-model",
|
|
Version: "0.1.0",
|
|
Description: "Deterministic example model provided by a Go dynamic plugin.",
|
|
InputTokenLimit: 4096,
|
|
OutputTokenLimit: 1024,
|
|
SupportedGenerationMethods: []string{"generateContent", "chat.completions"},
|
|
ContextLength: 4096,
|
|
MaxCompletionTokens: 1024,
|
|
SupportedParameters: []string{"model", "messages", "stream", "thinking", "reasoning_effort"},
|
|
SupportedInputModalities: []string{"text"},
|
|
SupportedOutputModalities: []string{"text"},
|
|
Thinking: &pluginapi.ThinkingSupport{ZeroAllowed: true, DynamicAllowed: true},
|
|
UserDefined: true,
|
|
}},
|
|
}, nil
|
|
}
|
|
|
|
func (p *examplePlugin) ModelsForAuth(ctx context.Context, req pluginapi.AuthModelRequest) (pluginapi.ModelResponse, error) {
|
|
return p.StaticModels(ctx, pluginapi.StaticModelRequest{Plugin: req.Plugin, Host: req.Host})
|
|
}
|
|
|
|
func (p *examplePlugin) Identifier() string {
|
|
return "plugin-example"
|
|
}
|
|
|
|
func (p *examplePlugin) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
|
|
if !strings.EqualFold(req.Provider, "plugin-example") {
|
|
return pluginapi.AuthParseResponse{}, nil
|
|
}
|
|
return pluginapi.AuthParseResponse{
|
|
Handled: true,
|
|
Auth: pluginapi.AuthData{
|
|
Provider: "plugin-example",
|
|
ID: req.FileName,
|
|
FileName: req.FileName,
|
|
Label: "Plugin Example",
|
|
StorageJSON: append([]byte(nil), req.RawJSON...),
|
|
Metadata: map[string]any{
|
|
"type": "plugin-example",
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (p *examplePlugin) StartLogin(context.Context, pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) {
|
|
return pluginapi.AuthLoginStartResponse{}, fmt.Errorf("plugin-example login is not interactive")
|
|
}
|
|
|
|
func (p *examplePlugin) PollLogin(context.Context, pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) {
|
|
return pluginapi.AuthLoginPollResponse{Status: pluginapi.AuthLoginStatusError, Message: "plugin-example login is not interactive"}, nil
|
|
}
|
|
|
|
func (p *examplePlugin) RefreshAuth(ctx context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
|
|
return pluginapi.AuthRefreshResponse{
|
|
Auth: pluginapi.AuthData{
|
|
Provider: req.AuthProvider,
|
|
ID: req.AuthID,
|
|
StorageJSON: append([]byte(nil), req.StorageJSON...),
|
|
Metadata: cloneAnyMap(req.Metadata),
|
|
Attributes: cloneStringMap(req.Attributes),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// A plugin can register multiple command-line flags.
|
|
// Flags are registered by priority. Existing native flags, reserved help/h flags,
|
|
// or higher-priority plugin flags win and cannot be registered again.
|
|
func (p *examplePlugin) RegisterCommandLine(context.Context, pluginapi.CommandLineRegistrationRequest) (pluginapi.CommandLineRegistrationResponse, error) {
|
|
return pluginapi.CommandLineRegistrationResponse{
|
|
Flags: []pluginapi.CommandLineFlag{
|
|
{
|
|
Name: "plugin-example-command",
|
|
Usage: "Run the example plugin command-line handler",
|
|
Type: "bool",
|
|
DefaultValue: "false",
|
|
},
|
|
{
|
|
Name: "plugin-example-message",
|
|
Usage: "Message passed to the example plugin command-line handler",
|
|
Type: "string",
|
|
DefaultValue: "hello",
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Global plugins.enabled=false or per-plugin enabled=false skips command-line execution after reload.
|
|
// The host passes every command-line argument and all triggered plugin flags to ExecuteCommandLine.
|
|
func (p *examplePlugin) ExecuteCommandLine(ctx context.Context, req pluginapi.CommandLineExecutionRequest) (pluginapi.CommandLineExecutionResponse, error) {
|
|
message := req.Flags["plugin-example-message"].Value
|
|
if triggeredMessage, ok := req.TriggeredFlags["plugin-example-message"]; ok {
|
|
message = triggeredMessage.Value
|
|
}
|
|
return pluginapi.CommandLineExecutionResponse{
|
|
Stdout: []byte(fmt.Sprintf("example plugin command executed with %d argument(s), message=%q\n", len(req.Args), message)),
|
|
}, nil
|
|
}
|
|
|
|
// A plugin can register multiple Management API routes.
|
|
// Management API routes are exact routes under /v0/management/ and cannot override
|
|
// native routes or higher-priority plugin routes that are already registered.
|
|
func (p *examplePlugin) RegisterManagement(context.Context, pluginapi.ManagementRegistrationRequest) (pluginapi.ManagementRegistrationResponse, error) {
|
|
return pluginapi.ManagementRegistrationResponse{
|
|
Routes: []pluginapi.ManagementRoute{
|
|
{
|
|
Method: http.MethodGet,
|
|
Path: "/plugins/example/status",
|
|
Menu: "Example Status",
|
|
Description: "Shows example plugin runtime status.",
|
|
Handler: p,
|
|
},
|
|
{
|
|
Method: http.MethodGet,
|
|
Path: "/plugins/example/capabilities",
|
|
Menu: "Example Capabilities",
|
|
Description: "Shows example plugin capability details.",
|
|
Handler: p,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Plugin Management API routes still require the normal Management API key,
|
|
// and are skipped when Home mode or Management API availability disables them.
|
|
func (p *examplePlugin) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
|
|
p.mu.Lock()
|
|
usageCount := p.usageCount
|
|
p.mu.Unlock()
|
|
|
|
body := []byte(fmt.Sprintf(`{"plugin":"example","usage_count":%d}`+"\n", usageCount))
|
|
if strings.HasSuffix(req.Path, "/capabilities") {
|
|
body = []byte(`{"plugin":"example","capabilities":["command-line","management-api","auth-provider","model-provider","frontend-auth","executor","raw-http","request-translator","request-normalizer","response-translator","response-normalizer","thinking-applier","usage"]}` + "\n")
|
|
}
|
|
|
|
return pluginapi.ManagementResponse{
|
|
StatusCode: http.StatusOK,
|
|
Headers: http.Header{
|
|
"Content-Type": []string{"application/json"},
|
|
},
|
|
Body: body,
|
|
}, nil
|
|
}
|
|
|
|
// Global plugins.enabled=false or per-plugin enabled=false skips plugin execution after reload.
|
|
func (p *examplePlugin) Authenticate(ctx context.Context, req pluginapi.FrontendAuthRequest) (pluginapi.FrontendAuthResponse, error) {
|
|
authenticated := req.Headers.Get("X-Plugin-Example") == "allow"
|
|
if !authenticated {
|
|
return pluginapi.FrontendAuthResponse{}, nil
|
|
}
|
|
|
|
return pluginapi.FrontendAuthResponse{
|
|
Authenticated: true,
|
|
Principal: "plugin-example-user",
|
|
Metadata: map[string]string{
|
|
"provider": "plugin-example",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// A plugin executor runs only for a matching auth when no native executor owns the provider.
|
|
func (p *examplePlugin) Execute(context.Context, pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) {
|
|
return pluginapi.ExecutorResponse{
|
|
Payload: []byte(`{"id":"plugin-example-response","object":"chat.completion","model":"plugin-example-model","choices":[{"index":0,"message":{"role":"assistant","content":"plugin example response"},"finish_reason":"stop"}]}`),
|
|
Headers: http.Header{
|
|
"Content-Type": []string{"application/json"},
|
|
},
|
|
Metadata: map[string]any{
|
|
"provider": "plugin-example",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (p *examplePlugin) ExecuteStream(context.Context, pluginapi.ExecutorRequest) (pluginapi.ExecutorStreamResponse, error) {
|
|
chunks := make(chan pluginapi.ExecutorStreamChunk, 1)
|
|
chunks <- pluginapi.ExecutorStreamChunk{
|
|
Payload: []byte(`{"id":"plugin-example-stream","object":"chat.completion.chunk","model":"plugin-example-model","choices":[{"index":0,"delta":{"content":"plugin example response"},"finish_reason":"stop"}]}`),
|
|
}
|
|
close(chunks)
|
|
|
|
return pluginapi.ExecutorStreamResponse{
|
|
Headers: http.Header{
|
|
"Content-Type": []string{"application/json"},
|
|
},
|
|
Chunks: chunks,
|
|
}, nil
|
|
}
|
|
|
|
func (p *examplePlugin) CountTokens(context.Context, pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) {
|
|
return pluginapi.ExecutorResponse{
|
|
Payload: []byte(`{"input_tokens":0,"output_tokens":0,"total_tokens":0}`),
|
|
Headers: http.Header{
|
|
"Content-Type": []string{"application/json"},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (p *examplePlugin) HttpRequest(ctx context.Context, req pluginapi.ExecutorHTTPRequest) (pluginapi.ExecutorHTTPResponse, error) {
|
|
resp, errDo := req.HTTPClient.Do(ctx, pluginapi.HTTPRequest{
|
|
Method: req.Method,
|
|
URL: req.URL,
|
|
Headers: req.Headers,
|
|
Body: req.Body,
|
|
})
|
|
if errDo != nil {
|
|
return pluginapi.ExecutorHTTPResponse{}, errDo
|
|
}
|
|
return pluginapi.ExecutorHTTPResponse{
|
|
StatusCode: resp.StatusCode,
|
|
Headers: resp.Headers,
|
|
Body: resp.Body,
|
|
}, nil
|
|
}
|
|
|
|
// Request/response translators run only when no native translator exists, and only the highest-priority plugin translator runs once.
|
|
func (p *examplePlugin) TranslateRequest(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) {
|
|
return payloadOrEmptyObject(req.Body), nil
|
|
}
|
|
|
|
// Normalizers run from higher priority to lower priority and are chained.
|
|
func (p *examplePlugin) NormalizeRequest(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) {
|
|
return payloadOrEmptyObject(req.Body), nil
|
|
}
|
|
|
|
func (p *examplePlugin) TranslateResponse(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) {
|
|
return payloadOrEmptyObject(req.Body), nil
|
|
}
|
|
|
|
func (p *examplePlugin) NormalizeResponse(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) {
|
|
return payloadOrEmptyObject(req.Body), nil
|
|
}
|
|
|
|
func (p *examplePlugin) ApplyThinking(ctx context.Context, req pluginapi.ThinkingApplyRequest) (pluginapi.PayloadResponse, error) {
|
|
var payload map[string]any
|
|
if len(req.Body) == 0 {
|
|
payload = map[string]any{}
|
|
} else if errUnmarshal := json.Unmarshal(req.Body, &payload); errUnmarshal != nil {
|
|
return pluginapi.PayloadResponse{}, errUnmarshal
|
|
}
|
|
payload["plugin_example_thinking"] = map[string]any{
|
|
"mode": req.Config.Mode,
|
|
"budget": req.Config.Budget,
|
|
"level": req.Config.Level,
|
|
}
|
|
out, errMarshal := json.Marshal(payload)
|
|
if errMarshal != nil {
|
|
return pluginapi.PayloadResponse{}, errMarshal
|
|
}
|
|
return pluginapi.PayloadResponse{Body: out}, nil
|
|
}
|
|
|
|
// If any plugin method panics, host disables that plugin for current process lifetime and never calls it again until restart.
|
|
func (p *examplePlugin) HandleUsage(ctx context.Context, record pluginapi.UsageRecord) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
p.usageCount++
|
|
}
|
|
|
|
func payloadOrEmptyObject(body []byte) pluginapi.PayloadResponse {
|
|
if len(body) == 0 {
|
|
return pluginapi.PayloadResponse{Body: []byte(`{}`)}
|
|
}
|
|
|
|
return pluginapi.PayloadResponse{Body: append([]byte(nil), body...)}
|
|
}
|
|
|
|
func cloneAnyMap(src map[string]any) map[string]any {
|
|
if len(src) == 0 {
|
|
return nil
|
|
}
|
|
dst := make(map[string]any, len(src))
|
|
for key, value := range src {
|
|
dst[key] = cloneAnyValue(value)
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func cloneAnyValue(value any) any {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
return cloneAnyMap(typed)
|
|
case map[string]string:
|
|
return cloneStringMap(typed)
|
|
case []any:
|
|
out := make([]any, len(typed))
|
|
for i, item := range typed {
|
|
out[i] = cloneAnyValue(item)
|
|
}
|
|
return out
|
|
case []string:
|
|
return append([]string(nil), typed...)
|
|
case http.Header:
|
|
return typed.Clone()
|
|
case url.Values:
|
|
return cloneValues(typed)
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func cloneStringMap(src map[string]string) map[string]string {
|
|
if len(src) == 0 {
|
|
return nil
|
|
}
|
|
dst := make(map[string]string, len(src))
|
|
for key, value := range src {
|
|
dst[key] = value
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func cloneValues(src url.Values) url.Values {
|
|
if len(src) == 0 {
|
|
return nil
|
|
}
|
|
dst := make(url.Values, len(src))
|
|
for key, values := range src {
|
|
dst[key] = append([]string(nil), values...)
|
|
}
|
|
return dst
|
|
}
|