439 lines · 14.6 KB · Go
RawDownload
package pluginhost import (	"context"	"encoding/json"	"fmt"	"os"	"path/filepath"	"runtime"	"testing" 	"github.com/router-for-me/CLIProxyAPI/v7/internal/config"	"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"	"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"	"gopkg.in/yaml.v3") type testSymbolLoader struct {	openCalls int	lookups   map[string]*testSymbolLookup} func newTestSymbolLoader() *testSymbolLoader {	return &testSymbolLoader{lookups: make(map[string]*testSymbolLookup)}} func (l *testSymbolLoader) Open(file pluginFile, host *Host) (pluginClient, error) {	l.openCalls++	lookup := l.lookups[file.ID]	if lookup == nil {		return nil, fmt.Errorf("missing test plugin for %s", file.Path)	}	return lookup, nil} type testSymbolLookup struct {	plugin              *testPlugin	active              pluginapi.Plugin	shutdownCalls       int	registerOverride    func([]byte) pluginapi.Plugin	reconfigureOverride func([]byte) pluginapi.Plugin	schemaVersion       uint32	lastLifecycle       rpcLifecycleRequest} func newTestSymbolLookup(plugin *testPlugin) *testSymbolLookup {	return &testSymbolLookup{plugin: plugin}} func (l *testSymbolLookup) Call(ctx context.Context, method string, request []byte) ([]byte, error) {	if err := ctx.Err(); err != nil {		return nil, err	}	switch method {	case pluginabi.MethodPluginRegister:		return l.callLifecycle(request, false)	case pluginabi.MethodPluginReconfigure:		return l.callLifecycle(request, true)	case pluginabi.MethodThinkingIdentifier:		if l.active.Capabilities.ThinkingApplier == nil {			return nil, fmt.Errorf("missing thinking applier")		}		return marshalRPCResult(rpcIdentifierResponse{Identifier: l.active.Capabilities.ThinkingApplier.Identifier()})	case pluginabi.MethodThinkingApply:		var req pluginapi.ThinkingApplyRequest		if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {			return nil, errUnmarshal		}		resp, errApply := l.active.Capabilities.ThinkingApplier.ApplyThinking(ctx, req)		if errApply != nil {			return nil, errApply		}		return marshalRPCResult(resp)	case pluginabi.MethodRequestInterceptBefore:		if l.active.Capabilities.RequestInterceptor == nil {			return nil, fmt.Errorf("missing request interceptor")		}		var req pluginapi.RequestInterceptRequest		if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {			return nil, errUnmarshal		}		resp, errIntercept := l.active.Capabilities.RequestInterceptor.InterceptRequestBeforeAuth(ctx, req)		if errIntercept != nil {			return nil, errIntercept		}		return marshalRPCResult(resp)	case pluginabi.MethodRequestInterceptAfter:		if l.active.Capabilities.RequestInterceptor == nil {			return nil, fmt.Errorf("missing request interceptor")		}		var req pluginapi.RequestInterceptRequest		if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {			return nil, errUnmarshal		}		resp, errIntercept := l.active.Capabilities.RequestInterceptor.InterceptRequestAfterAuth(ctx, req)		if errIntercept != nil {			return nil, errIntercept		}		return marshalRPCResult(resp)	case pluginabi.MethodRequestComplete:		if l.active.Capabilities.RequestLifecyclePlugin == nil {			return nil, fmt.Errorf("missing request lifecycle plugin")		}		var req pluginapi.RequestCompletion		if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {			return nil, errUnmarshal		}		if errComplete := l.active.Capabilities.RequestLifecyclePlugin.HandleRequestComplete(ctx, req); errComplete != nil {			return nil, errComplete		}		return marshalRPCResult(rpcEmptyResponse{})	case pluginabi.MethodResponseInterceptAfter:		if l.active.Capabilities.ResponseInterceptor == nil {			return nil, fmt.Errorf("missing response interceptor")		}		var req pluginapi.ResponseInterceptRequest		if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {			return nil, errUnmarshal		}		resp, errIntercept := l.active.Capabilities.ResponseInterceptor.InterceptResponse(ctx, req)		if errIntercept != nil {			return nil, errIntercept		}		return marshalRPCResult(resp)	case pluginabi.MethodResponseInterceptStreamChunk:		if l.active.Capabilities.StreamChunkInterceptor == nil {			return nil, fmt.Errorf("missing stream chunk interceptor")		}		var req pluginapi.StreamChunkInterceptRequest		if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {			return nil, errUnmarshal		}		resp, errIntercept := l.active.Capabilities.StreamChunkInterceptor.InterceptStreamChunk(ctx, req)		if errIntercept != nil {			return nil, errIntercept		}		return marshalRPCResult(resp)	case pluginabi.MethodAuthIdentifier:		if l.active.Capabilities.AuthProvider == nil {			return nil, fmt.Errorf("missing auth provider")		}		return marshalRPCResult(rpcIdentifierResponse{Identifier: l.active.Capabilities.AuthProvider.Identifier()})	case pluginabi.MethodSchedulerPick:		if l.active.Capabilities.Scheduler == nil {			return nil, fmt.Errorf("missing scheduler")		}		var req pluginapi.SchedulerPickRequest		if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {			return nil, errUnmarshal		}		resp, errPick := l.active.Capabilities.Scheduler.Pick(ctx, req)		if errPick != nil {			return nil, errPick		}		return marshalRPCResult(resp)	case pluginabi.MethodModelRoute:		if l.active.Capabilities.ModelRouter == nil {			return nil, fmt.Errorf("missing model router")		}		var req pluginapi.ModelRouteRequest		if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {			return nil, errUnmarshal		}		resp, errRoute := l.active.Capabilities.ModelRouter.RouteModel(ctx, req)		if errRoute != nil {			return nil, errRoute		}		return marshalRPCResult(resp)	case pluginabi.MethodUsageHandle:		if l.active.Capabilities.UsagePlugin == nil {			return marshalRPCResult(rpcEmptyResponse{})		}		var record pluginapi.UsageRecord		if errUnmarshal := json.Unmarshal(request, &record); errUnmarshal != nil {			return nil, errUnmarshal		}		l.active.Capabilities.UsagePlugin.HandleUsage(ctx, record)		return marshalRPCResult(rpcEmptyResponse{})	case pluginabi.MethodQuotaIdentifier:		if l.active.Capabilities.QuotaProvider == nil {			return nil, fmt.Errorf("missing quota provider")		}		return marshalRPCResult(rpcIdentifierResponse{Identifier: l.active.Capabilities.QuotaProvider.Identifier()})	case pluginabi.MethodQuotaDescribe:		if l.active.Capabilities.QuotaProvider == nil {			return nil, fmt.Errorf("missing quota provider")		}		var req pluginapi.QuotaDescribeRequest		if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {			return nil, errUnmarshal		}		resp, errDescribe := l.active.Capabilities.QuotaProvider.DescribeQuota(ctx, req)		if errDescribe != nil {			return nil, errDescribe		}		return marshalRPCResult(resp)	case pluginabi.MethodQuotaFetch:		if l.active.Capabilities.QuotaProvider == nil {			return nil, fmt.Errorf("missing quota provider")		}		var req rpcQuotaFetchRequest		if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {			return nil, errUnmarshal		}		resp, errFetch := l.active.Capabilities.QuotaProvider.FetchQuota(ctx, req.QuotaFetchRequest)		if errFetch != nil {			return nil, errFetch		}		return marshalRPCResult(resp)	case pluginabi.MethodQuotaReset:		if l.active.Capabilities.QuotaProvider == nil {			return nil, fmt.Errorf("missing quota provider")		}		var req rpcQuotaResetRequest		if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {			return nil, errUnmarshal		}		resp, errReset := l.active.Capabilities.QuotaProvider.ResetQuota(ctx, req.QuotaResetRequest)		if errReset != nil {			return nil, errReset		}		return marshalRPCResult(resp)	default:		return nil, fmt.Errorf("missing test method %s", method)	}} func (l *testSymbolLookup) Shutdown() {	l.shutdownCalls++} func (l *testSymbolLookup) callLifecycle(request []byte, reload bool) ([]byte, error) {	var req rpcLifecycleRequest	if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {		return nil, errUnmarshal	}	l.lastLifecycle = req	var plugin pluginapi.Plugin	if reload {		if l.reconfigureOverride != nil {			plugin = l.reconfigureOverride(req.ConfigYAML)		} else {			plugin = l.plugin.Reconfigure(req.ConfigYAML)		}	} else {		if l.registerOverride != nil {			plugin = l.registerOverride(req.ConfigYAML)		} else {			plugin = l.plugin.Register(req.ConfigYAML)		}	}	l.active = plugin	schemaVersion := l.schemaVersion	if schemaVersion == 0 {		schemaVersion = pluginabi.SchemaVersion	}	return marshalRPCResult(rpcRegistration{		SchemaVersion: schemaVersion,		Metadata:      plugin.Metadata,		Capabilities:  rpcCapabilitiesFromPlugin(plugin),	})} type testPlugin struct {	registerCalls     int	reconfigureCalls  int	registerResult    pluginapi.Plugin	reconfigureResult pluginapi.Plugin	panicOnRegister   bool	panicOnReload     bool} func (p *testPlugin) Register([]byte) pluginapi.Plugin {	p.registerCalls++	if p.panicOnRegister {		panic("register panic")	}	return p.registerResult} func (p *testPlugin) Reconfigure([]byte) pluginapi.Plugin {	p.reconfigureCalls++	if p.panicOnReload {		panic("reconfigure panic")	}	return p.reconfigureResult} func validTestPlugin(name string) pluginapi.Plugin {	return pluginapi.Plugin{		Metadata: pluginapi.Metadata{			Name:             name,			Version:          "1.0.0",			Author:           "test",			GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",		},		Capabilities: pluginapi.Capabilities{			UsagePlugin: testUsageCapability{},		},	}} type testUsageCapability struct{} func (testUsageCapability) HandleUsage(ctx context.Context, record pluginapi.UsageRecord) {} type requestLifecyclePluginFunc func(context.Context, pluginapi.RequestCompletion) func (f requestLifecyclePluginFunc) HandleRequestComplete(ctx context.Context, completion pluginapi.RequestCompletion) error {	f(ctx, completion)	return nil} type testThinkingCapability struct {	provider string} func (c testThinkingCapability) Identifier() string {	return c.provider} func (c testThinkingCapability) ApplyThinking(ctx context.Context, req pluginapi.ThinkingApplyRequest) (pluginapi.PayloadResponse, error) {	var payload map[string]any	if errUnmarshal := json.Unmarshal(req.Body, &payload); errUnmarshal != nil {		return pluginapi.PayloadResponse{}, errUnmarshal	}	payload["plugin"] = c.provider	payload["thinking_budget"] = req.Config.Budget	out, errMarshal := json.Marshal(payload)	if errMarshal != nil {		return pluginapi.PayloadResponse{}, errMarshal	}	return pluginapi.PayloadResponse{Body: out}, nil} type requestInterceptorFunc func(context.Context, pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) func (f requestInterceptorFunc) InterceptRequestBeforeAuth(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {	if f == nil {		return pluginapi.RequestInterceptResponse{}, fmt.Errorf("missing request interceptor callback")	}	return f(ctx, req)} func (f requestInterceptorFunc) InterceptRequestAfterAuth(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {	if f == nil {		return pluginapi.RequestInterceptResponse{}, fmt.Errorf("missing request interceptor callback")	}	return f(ctx, req)} type schedulerFunc func(context.Context, pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) func (f schedulerFunc) Pick(ctx context.Context, req pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) {	if f == nil {		return pluginapi.SchedulerPickResponse{}, fmt.Errorf("missing scheduler callback")	}	return f(ctx, req)} type modelRouterFunc func(context.Context, pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) func (f modelRouterFunc) RouteModel(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {	if f == nil {		return pluginapi.ModelRouteResponse{}, fmt.Errorf("missing model router callback")	}	return f(ctx, req)} type responseInterceptorFunc struct {	interceptResponse    func(context.Context, pluginapi.ResponseInterceptRequest) (pluginapi.ResponseInterceptResponse, error)	interceptStreamChunk func(context.Context, pluginapi.StreamChunkInterceptRequest) (pluginapi.StreamChunkInterceptResponse, error)} func (f responseInterceptorFunc) InterceptResponse(ctx context.Context, req pluginapi.ResponseInterceptRequest) (pluginapi.ResponseInterceptResponse, error) {	if f.interceptResponse == nil {		return pluginapi.ResponseInterceptResponse{}, fmt.Errorf("missing response interceptor callback")	}	return f.interceptResponse(ctx, req)} func (f responseInterceptorFunc) InterceptStreamChunk(ctx context.Context, req pluginapi.StreamChunkInterceptRequest) (pluginapi.StreamChunkInterceptResponse, error) {	if f.interceptStreamChunk == nil {		return pluginapi.StreamChunkInterceptResponse{}, fmt.Errorf("missing stream chunk interceptor callback")	}	return f.interceptStreamChunk(ctx, req)} func makePluginDir(t *testing.T, ids ...string) string {	t.Helper()	root := t.TempDir()	archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)	if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {		t.Fatalf("MkdirAll() error = %v", errMkdirAll)	}	for _, id := range ids {		path := filepath.Join(archDir, id+pluginExtension(runtime.GOOS))		if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {			t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)		}	}	return root} func makeVersionedPluginDir(t *testing.T, id string, versions ...string) (string, map[string]string) {	t.Helper()	root := t.TempDir()	paths := make(map[string]string, len(versions))	for _, version := range versions {		paths[version] = writeVersionedPluginFile(t, root, id, version)	}	return root, paths} func writeVersionedPluginFile(t *testing.T, root, id, version string) string {	t.Helper()	archDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)	if errMkdirAll := os.MkdirAll(archDir, 0o755); errMkdirAll != nil {		t.Fatalf("MkdirAll() error = %v", errMkdirAll)	}	path := filepath.Join(archDir, fmt.Sprintf("%s-v%s%s", id, version, pluginExtension(runtime.GOOS)))	if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {		t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)	}	return path} func enabledPluginConfigWithStoreVersion(t *testing.T, version string) config.PluginInstanceConfig {	t.Helper()	var node yaml.Node	if errDecode := yaml.Unmarshal([]byte(fmt.Sprintf("store:\n  version: %s\n", version)), &node); errDecode != nil {		t.Fatalf("yaml.Unmarshal() error = %v", errDecode)	}	enabled := true	return config.PluginInstanceConfig{		Enabled: &enabled,		Raw:     *node.Content[0],	}}