From fabf06154f519cf128ae97166e3c834fa21744ef Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 9 Jun 2026 10:56:58 +0800 Subject: [PATCH] feat(access, pluginhost): add support for exclusive frontend auth providers - Introduced `FrontendAuthProviderExclusive` capability to restrict authentication to a single selected provider. - Added `SetExclusiveProvider` and `ClearExclusiveProvider` methods for managing exclusive providers in the access registry. - Updated `pluginhost` to prioritize and enforce exclusive providers based on plugin priority and ID. - Enhanced RPC capabilities schema to include `FrontendAuthProviderExclusive` field. - Added example plugin and tests for exclusive frontend auth behavior. --- examples/plugin/README.md | 1 + examples/plugin/README_CN.md | 1 + .../plugin/frontend-auth-exclusive/README.md | 19 ++ .../plugin/frontend-auth-exclusive/go/go.mod | 7 + .../plugin/frontend-auth-exclusive/go/main.go | 194 ++++++++++++++++++ internal/pluginhost/adapters.go | 24 +++ internal/pluginhost/adapters_test.go | 171 +++++++++++++++ internal/pluginhost/rpc_client.go | 7 +- internal/pluginhost/rpc_schema.go | 82 ++++---- internal/pluginhost/rpc_schema_test.go | 40 ++++ sdk/access/registry.go | 28 ++- sdk/access/registry_test.go | 81 ++++++++ sdk/pluginapi/types.go | 2 + 13 files changed, 611 insertions(+), 46 deletions(-) create mode 100644 examples/plugin/frontend-auth-exclusive/README.md create mode 100644 examples/plugin/frontend-auth-exclusive/go/go.mod create mode 100644 examples/plugin/frontend-auth-exclusive/go/main.go create mode 100644 internal/pluginhost/rpc_schema_test.go create mode 100644 sdk/access/registry_test.go diff --git a/examples/plugin/README.md b/examples/plugin/README.md index 7dcb28ac0..668ada8d7 100644 --- a/examples/plugin/README.md +++ b/examples/plugin/README.md @@ -8,6 +8,7 @@ This directory contains standard dynamic library plugin examples for the CLIProx - `model/`: model capability only. - `auth/`: auth provider capability only. - `frontend-auth/`: frontend auth provider capability only. +- `frontend-auth-exclusive/`: frontend auth provider that becomes the only request authentication provider when selected. - `executor/`: executor capability only. - `protocol-format/`: minimal executor focused on input/output format declarations. - `request-translator/`: request translation capability only. diff --git a/examples/plugin/README_CN.md b/examples/plugin/README_CN.md index 9841b8bc2..a489ee022 100644 --- a/examples/plugin/README_CN.md +++ b/examples/plugin/README_CN.md @@ -8,6 +8,7 @@ - `model/`:只演示模型能力。 - `auth/`:只演示认证提供方能力。 - `frontend-auth/`:只演示前端认证提供方能力。 +- `frontend-auth-exclusive/`:演示被选中后成为唯一请求认证方式的前端认证提供方。 - `executor/`:只演示执行器能力。 - `protocol-format/`:使用最小执行器重点演示输入和输出格式声明。 - `request-translator/`:只演示请求转换能力。 diff --git a/examples/plugin/frontend-auth-exclusive/README.md b/examples/plugin/frontend-auth-exclusive/README.md new file mode 100644 index 000000000..16e63a155 --- /dev/null +++ b/examples/plugin/frontend-auth-exclusive/README.md @@ -0,0 +1,19 @@ +# Frontend Auth Exclusive Plugin Example + +This example registers a frontend auth provider with `frontend_auth_provider_exclusive: true`. + +When enabled and selected, this provider becomes the only request authentication provider. Built-in config API keys and other frontend auth providers do not authenticate requests while this provider is active. + +The example accepts requests that include: + +```http +X-Example-Frontend-Auth: exclusive +``` + +Build: + +```bash +cd examples/plugin/frontend-auth-exclusive/go +go build -buildmode=c-shared -o /tmp/cliproxy-frontend-auth-exclusive.dylib . +``` + diff --git a/examples/plugin/frontend-auth-exclusive/go/go.mod b/examples/plugin/frontend-auth-exclusive/go/go.mod new file mode 100644 index 000000000..c5f0e70a4 --- /dev/null +++ b/examples/plugin/frontend-auth-exclusive/go/go.mod @@ -0,0 +1,7 @@ +module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/frontend-auth-exclusive/go + +go 1.26.0 + +require github.com/router-for-me/CLIProxyAPI/v7 v7.0.0 + +replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../.. diff --git a/examples/plugin/frontend-auth-exclusive/go/main.go b/examples/plugin/frontend-auth-exclusive/go/main.go new file mode 100644 index 000000000..9896380ad --- /dev/null +++ b/examples/plugin/frontend-auth-exclusive/go/main.go @@ -0,0 +1,194 @@ +package main + +/* +#include +#include + +typedef struct { + void* ptr; + size_t len; +} cliproxy_buffer; + +typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*); +typedef void (*cliproxy_host_free_fn)(void*, size_t); + +typedef struct { + uint32_t abi_version; + void* host_ctx; + cliproxy_host_call_fn call; + cliproxy_host_free_fn free_buffer; +} cliproxy_host_api; + +typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*); +typedef void (*cliproxy_plugin_free_fn)(void*, size_t); +typedef void (*cliproxy_plugin_shutdown_fn)(void); + +typedef struct { + uint32_t abi_version; + cliproxy_plugin_call_fn call; + cliproxy_plugin_free_fn free_buffer; + cliproxy_plugin_shutdown_fn shutdown; +} cliproxy_plugin_api; + +extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*); +extern void cliproxyPluginFree(void*, size_t); +extern void cliproxyPluginShutdown(void); +*/ +import "C" + +import ( + "encoding/json" + "unsafe" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +type envelope struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result,omitempty"` + Error *envelopeError `json:"error,omitempty"` +} + +type envelopeError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type registration struct { + SchemaVersion uint32 `json:"schema_version"` + Metadata pluginapi.Metadata `json:"metadata"` + Capabilities capabilities `json:"capabilities"` +} + +type capabilities struct { + FrontendAuthProvider bool `json:"frontend_auth_provider"` + FrontendAuthProviderExclusive bool `json:"frontend_auth_provider_exclusive"` +} + +type identifierResponse struct { + Identifier string `json:"identifier"` +} + +func main() {} + +//export cliproxy_plugin_init +func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int { + _ = host + if plugin == nil { + return 1 + } + plugin.abi_version = C.uint32_t(pluginabi.ABIVersion) + plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall) + plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree) + plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown) + return 0 +} + +//export cliproxyPluginCall +func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int { + if response != nil { + response.ptr = nil + response.len = 0 + } + if method == nil { + writeResponse(response, errorEnvelope("invalid_method", "method is required")) + return 1 + } + var requestBytes []byte + if request != nil && requestLen > 0 { + requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen)) + } + raw, errHandle := handleMethod(C.GoString(method), requestBytes) + if errHandle != nil { + writeResponse(response, errorEnvelope("plugin_error", errHandle.Error())) + return 1 + } + writeResponse(response, raw) + return 0 +} + +//export cliproxyPluginFree +func cliproxyPluginFree(ptr unsafe.Pointer, len C.size_t) { + if ptr != nil { + C.free(ptr) + } + _ = len +} + +//export cliproxyPluginShutdown +func cliproxyPluginShutdown() {} + +func handleMethod(method string, request []byte) ([]byte, error) { + switch method { + case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure: + return okEnvelope(exampleRegistration()) + case pluginabi.MethodFrontendAuthIdentifier: + return okEnvelope(identifierResponse{Identifier: "example-frontend-auth-exclusive-go"}) + case pluginabi.MethodFrontendAuthAuthenticate: + return authenticate(request) + default: + return errorEnvelope("unknown_method", "unknown method: "+method), nil + } +} + +func exampleRegistration() registration { + return registration{ + SchemaVersion: pluginabi.SchemaVersion, + Metadata: pluginapi.Metadata{ + Name: "example-frontend-auth-exclusive-go", + Version: "0.1.0", + Author: "router-for-me", + GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI", + Logo: "https://example.invalid/example-frontend-auth-exclusive-go.png", + ConfigFields: []pluginapi.ConfigField{}, + }, + Capabilities: capabilities{ + FrontendAuthProvider: true, + FrontendAuthProviderExclusive: true, + }, + } +} + +func authenticate(request []byte) ([]byte, error) { + var req pluginapi.FrontendAuthRequest + if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil { + return okEnvelope(pluginapi.FrontendAuthResponse{Authenticated: false}) + } + if req.Headers.Get("X-Example-Frontend-Auth") != "exclusive" { + return okEnvelope(pluginapi.FrontendAuthResponse{Authenticated: false}) + } + return okEnvelope(pluginapi.FrontendAuthResponse{ + Authenticated: true, + Principal: "example-frontend-auth-exclusive-go", + Metadata: map[string]string{ + "mode": "exclusive", + "provider": "example-frontend-auth-exclusive-go", + }, + }) +} + +func okEnvelope(v any) ([]byte, error) { + raw, errMarshal := json.Marshal(v) + if errMarshal != nil { + return nil, errMarshal + } + return json.Marshal(envelope{OK: true, Result: raw}) +} + +func errorEnvelope(code, message string) []byte { + raw, _ := json.Marshal(envelope{OK: false, Error: &envelopeError{Code: code, Message: message}}) + return raw +} + +func writeResponse(response *C.cliproxy_buffer, raw []byte) { + if response == nil || len(raw) == 0 { + return + } + ptr := C.CBytes(raw) + if ptr == nil { + return + } + response.ptr = ptr + response.len = C.size_t(len(raw)) +} diff --git a/internal/pluginhost/adapters.go b/internal/pluginhost/adapters.go index ba16e6d1c..3c5645460 100644 --- a/internal/pluginhost/adapters.go +++ b/internal/pluginhost/adapters.go @@ -1037,7 +1037,14 @@ func (h *Host) RegisterFrontendAuthProviders() { return } + type exclusiveFrontendAuthCandidate struct { + key string + pluginID string + priority int + } + nextKeys := make(map[string]struct{}) + var bestExclusive exclusiveFrontendAuthCandidate for _, record := range h.Snapshot().records { provider := record.plugin.Capabilities.FrontendAuthProvider if provider == nil || h.isPluginFused(record.id) { @@ -1054,8 +1061,25 @@ func (h *Host) RegisterFrontendAuthProviders() { } sdkaccess.RegisterProvider(key, adapter) nextKeys[key] = struct{}{} + if record.plugin.Capabilities.FrontendAuthProviderExclusive { + candidate := exclusiveFrontendAuthCandidate{ + key: key, + pluginID: record.id, + priority: record.priority, + } + if bestExclusive.key == "" || + candidate.priority > bestExclusive.priority || + (candidate.priority == bestExclusive.priority && candidate.pluginID < bestExclusive.pluginID) { + bestExclusive = candidate + } + } } + if bestExclusive.key != "" { + sdkaccess.SetExclusiveProvider(bestExclusive.key) + } else { + sdkaccess.ClearExclusiveProvider() + } h.pruneStaleAccessProviders(nextKeys) } diff --git a/internal/pluginhost/adapters_test.go b/internal/pluginhost/adapters_test.go index 2207efff3..e7ae6d565 100644 --- a/internal/pluginhost/adapters_test.go +++ b/internal/pluginhost/adapters_test.go @@ -2051,6 +2051,177 @@ func TestRegisterFrontendAuthProvidersIdentifierPanicFusesPlugin(t *testing.T) { } } +func TestRegisterFrontendAuthProvidersSelectsHighestPriorityExclusiveProvider(t *testing.T) { + lowKey := "plugin:exclusive-low:custom-auth" + highKey := "plugin:exclusive-high:custom-auth" + normalKey := "plugin:normal-auth:custom-auth" + for _, key := range []string{lowKey, highKey, normalKey} { + sdkaccess.UnregisterProvider(key) + defer sdkaccess.UnregisterProvider(key) + } + sdkaccess.ClearExclusiveProvider() + defer sdkaccess.ClearExclusiveProvider() + + host := newHostWithRecords( + capabilityRecord{ + id: "exclusive-low", + priority: 1, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + FrontendAuthProvider: frontendAuthProviderFunc{identifier: "custom-auth"}, + FrontendAuthProviderExclusive: true, + }}, + }, + capabilityRecord{ + id: "exclusive-high", + priority: 10, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + FrontendAuthProvider: frontendAuthProviderFunc{identifier: "custom-auth"}, + FrontendAuthProviderExclusive: true, + }}, + }, + capabilityRecord{ + id: "normal-auth", + priority: 20, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + FrontendAuthProvider: frontendAuthProviderFunc{identifier: "custom-auth"}, + }}, + }, + ) + + host.RegisterFrontendAuthProviders() + + providers := sdkaccess.RegisteredProviders() + if len(providers) != 1 { + t.Fatalf("RegisteredProviders() len = %d, want 1", len(providers)) + } + if providers[0].Identifier() != highKey { + t.Fatalf("exclusive provider = %q, want %q", providers[0].Identifier(), highKey) + } +} + +func TestRegisterFrontendAuthProvidersSelectsExclusiveProviderByPluginIDWhenPriorityTies(t *testing.T) { + alphaKey := "plugin:alpha-auth:custom-auth" + betaKey := "plugin:beta-auth:custom-auth" + for _, key := range []string{alphaKey, betaKey} { + sdkaccess.UnregisterProvider(key) + defer sdkaccess.UnregisterProvider(key) + } + sdkaccess.ClearExclusiveProvider() + defer sdkaccess.ClearExclusiveProvider() + + host := newHostWithRecords( + capabilityRecord{ + id: "beta-auth", + priority: 5, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + FrontendAuthProvider: frontendAuthProviderFunc{identifier: "custom-auth"}, + FrontendAuthProviderExclusive: true, + }}, + }, + capabilityRecord{ + id: "alpha-auth", + priority: 5, + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + FrontendAuthProvider: frontendAuthProviderFunc{identifier: "custom-auth"}, + FrontendAuthProviderExclusive: true, + }}, + }, + ) + + host.RegisterFrontendAuthProviders() + + providers := sdkaccess.RegisteredProviders() + if len(providers) != 1 { + t.Fatalf("RegisteredProviders() len = %d, want 1", len(providers)) + } + if providers[0].Identifier() != alphaKey { + t.Fatalf("exclusive provider = %q, want %q", providers[0].Identifier(), alphaKey) + } +} + +func TestRegisterFrontendAuthProvidersClearsExclusiveProviderWhenExclusivePluginRemoved(t *testing.T) { + exclusiveKey := "plugin:exclusive-auth:custom-auth" + normalKey := "plugin:normal-auth:custom-auth" + for _, key := range []string{exclusiveKey, normalKey} { + sdkaccess.UnregisterProvider(key) + defer sdkaccess.UnregisterProvider(key) + } + sdkaccess.ClearExclusiveProvider() + defer sdkaccess.ClearExclusiveProvider() + + host := newHostWithRecords( + capabilityRecord{ + id: "exclusive-auth", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + FrontendAuthProvider: frontendAuthProviderFunc{identifier: "custom-auth"}, + FrontendAuthProviderExclusive: true, + }}, + }, + capabilityRecord{ + id: "normal-auth", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + FrontendAuthProvider: frontendAuthProviderFunc{identifier: "custom-auth"}, + }}, + }, + ) + + host.RegisterFrontendAuthProviders() + if got := sdkaccess.RegisteredProviders(); len(got) != 1 || got[0].Identifier() != exclusiveKey { + t.Fatalf("exclusive RegisteredProviders() = %#v, want only %q", got, exclusiveKey) + } + + host.snapshot.Store(&Snapshot{enabled: true, records: []capabilityRecord{ + { + id: "normal-auth", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + FrontendAuthProvider: frontendAuthProviderFunc{identifier: "custom-auth"}, + }}, + }, + }}) + host.RegisterFrontendAuthProviders() + + providers := sdkaccess.RegisteredProviders() + if len(providers) != 1 { + t.Fatalf("RegisteredProviders() len = %d, want 1", len(providers)) + } + if providers[0].Identifier() != normalKey { + t.Fatalf("restored provider = %q, want %q", providers[0].Identifier(), normalKey) + } +} + +func TestRegisterFrontendAuthProvidersIgnoresExclusiveWithoutFrontendAuthProvider(t *testing.T) { + normalKey := "plugin:normal-auth:custom-auth" + sdkaccess.UnregisterProvider(normalKey) + sdkaccess.ClearExclusiveProvider() + defer sdkaccess.UnregisterProvider(normalKey) + defer sdkaccess.ClearExclusiveProvider() + + host := newHostWithRecords( + capabilityRecord{ + id: "exclusive-without-provider", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + FrontendAuthProviderExclusive: true, + }}, + }, + capabilityRecord{ + id: "normal-auth", + plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ + FrontendAuthProvider: frontendAuthProviderFunc{identifier: "custom-auth"}, + }}, + }, + ) + + host.RegisterFrontendAuthProviders() + + providers := sdkaccess.RegisteredProviders() + if len(providers) != 1 { + t.Fatalf("RegisteredProviders() len = %d, want 1", len(providers)) + } + if providers[0].Identifier() != normalKey { + t.Fatalf("provider = %q, want %q", providers[0].Identifier(), normalKey) + } +} + func TestUsageAdapterUsesCurrentSnapshotCapability(t *testing.T) { oldCalls := 0 newCalls := 0 diff --git a/internal/pluginhost/rpc_client.go b/internal/pluginhost/rpc_client.go index 5e7985a24..8addde684 100644 --- a/internal/pluginhost/rpc_client.go +++ b/internal/pluginhost/rpc_client.go @@ -52,9 +52,10 @@ func registerRPCPlugin(ctx context.Context, host *Host, id string, client plugin plugin := pluginapi.Plugin{ Metadata: resp.Metadata, Capabilities: pluginapi.Capabilities{ - ExecutorModelScope: resp.Capabilities.ExecutorModelScope, - ExecutorInputFormats: append([]string(nil), resp.Capabilities.ExecutorInputFormats...), - ExecutorOutputFormats: append([]string(nil), resp.Capabilities.ExecutorOutputFormats...), + FrontendAuthProviderExclusive: resp.Capabilities.FrontendAuthProvider && resp.Capabilities.FrontendAuthProviderExclusive, + ExecutorModelScope: resp.Capabilities.ExecutorModelScope, + ExecutorInputFormats: append([]string(nil), resp.Capabilities.ExecutorInputFormats...), + ExecutorOutputFormats: append([]string(nil), resp.Capabilities.ExecutorOutputFormats...), }, } if resp.Capabilities.ModelRegistrar { diff --git a/internal/pluginhost/rpc_schema.go b/internal/pluginhost/rpc_schema.go index 4d8059939..b579354f4 100644 --- a/internal/pluginhost/rpc_schema.go +++ b/internal/pluginhost/rpc_schema.go @@ -18,26 +18,27 @@ type rpcRegistration struct { } type rpcCapabilities struct { - ModelRegistrar bool `json:"model_registrar"` - ModelProvider bool `json:"model_provider"` - AuthProvider bool `json:"auth_provider"` - FrontendAuthProvider bool `json:"frontend_auth_provider"` - Executor bool `json:"executor"` - ExecutorModelScope pluginapi.ExecutorModelScope `json:"executor_model_scope"` - ExecutorInputFormats []string `json:"executor_input_formats,omitempty"` - ExecutorOutputFormats []string `json:"executor_output_formats,omitempty"` - RequestTranslator bool `json:"request_translator"` - RequestNormalizer bool `json:"request_normalizer"` - RequestInterceptor bool `json:"request_interceptor"` - ResponseTranslator bool `json:"response_translator"` - ResponseBeforeTranslator bool `json:"response_before_translator"` - ResponseAfterTranslator bool `json:"response_after_translator"` - ResponseInterceptor bool `json:"response_interceptor"` - StreamChunkInterceptor bool `json:"response_stream_interceptor"` - ThinkingApplier bool `json:"thinking_applier"` - UsagePlugin bool `json:"usage_plugin"` - CommandLinePlugin bool `json:"command_line_plugin"` - ManagementAPI bool `json:"management_api"` + ModelRegistrar bool `json:"model_registrar"` + ModelProvider bool `json:"model_provider"` + AuthProvider bool `json:"auth_provider"` + FrontendAuthProvider bool `json:"frontend_auth_provider"` + FrontendAuthProviderExclusive bool `json:"frontend_auth_provider_exclusive"` + Executor bool `json:"executor"` + ExecutorModelScope pluginapi.ExecutorModelScope `json:"executor_model_scope"` + ExecutorInputFormats []string `json:"executor_input_formats,omitempty"` + ExecutorOutputFormats []string `json:"executor_output_formats,omitempty"` + RequestTranslator bool `json:"request_translator"` + RequestNormalizer bool `json:"request_normalizer"` + RequestInterceptor bool `json:"request_interceptor"` + ResponseTranslator bool `json:"response_translator"` + ResponseBeforeTranslator bool `json:"response_before_translator"` + ResponseAfterTranslator bool `json:"response_after_translator"` + ResponseInterceptor bool `json:"response_interceptor"` + StreamChunkInterceptor bool `json:"response_stream_interceptor"` + ThinkingApplier bool `json:"thinking_applier"` + UsagePlugin bool `json:"usage_plugin"` + CommandLinePlugin bool `json:"command_line_plugin"` + ManagementAPI bool `json:"management_api"` } type rpcIdentifierResponse struct { @@ -94,26 +95,27 @@ type rpcEmptyResponse struct{} func rpcCapabilitiesFromPlugin(plugin pluginapi.Plugin) rpcCapabilities { caps := plugin.Capabilities return rpcCapabilities{ - ModelRegistrar: caps.ModelRegistrar != nil, - ModelProvider: caps.ModelProvider != nil, - AuthProvider: caps.AuthProvider != nil, - FrontendAuthProvider: caps.FrontendAuthProvider != nil, - Executor: caps.Executor != nil, - ExecutorModelScope: normalizedExecutorModelScope(caps), - ExecutorInputFormats: append([]string(nil), caps.ExecutorInputFormats...), - ExecutorOutputFormats: append([]string(nil), caps.ExecutorOutputFormats...), - RequestTranslator: caps.RequestTranslator != nil, - RequestNormalizer: caps.RequestNormalizer != nil, - RequestInterceptor: caps.RequestInterceptor != nil, - ResponseTranslator: caps.ResponseTranslator != nil, - ResponseBeforeTranslator: caps.ResponseBeforeTranslator != nil, - ResponseAfterTranslator: caps.ResponseAfterTranslator != nil, - ResponseInterceptor: caps.ResponseInterceptor != nil, - StreamChunkInterceptor: caps.StreamChunkInterceptor != nil, - ThinkingApplier: caps.ThinkingApplier != nil, - UsagePlugin: caps.UsagePlugin != nil, - CommandLinePlugin: caps.CommandLinePlugin != nil, - ManagementAPI: caps.ManagementAPI != nil, + ModelRegistrar: caps.ModelRegistrar != nil, + ModelProvider: caps.ModelProvider != nil, + AuthProvider: caps.AuthProvider != nil, + FrontendAuthProvider: caps.FrontendAuthProvider != nil, + FrontendAuthProviderExclusive: caps.FrontendAuthProvider != nil && caps.FrontendAuthProviderExclusive, + Executor: caps.Executor != nil, + ExecutorModelScope: normalizedExecutorModelScope(caps), + ExecutorInputFormats: append([]string(nil), caps.ExecutorInputFormats...), + ExecutorOutputFormats: append([]string(nil), caps.ExecutorOutputFormats...), + RequestTranslator: caps.RequestTranslator != nil, + RequestNormalizer: caps.RequestNormalizer != nil, + RequestInterceptor: caps.RequestInterceptor != nil, + ResponseTranslator: caps.ResponseTranslator != nil, + ResponseBeforeTranslator: caps.ResponseBeforeTranslator != nil, + ResponseAfterTranslator: caps.ResponseAfterTranslator != nil, + ResponseInterceptor: caps.ResponseInterceptor != nil, + StreamChunkInterceptor: caps.StreamChunkInterceptor != nil, + ThinkingApplier: caps.ThinkingApplier != nil, + UsagePlugin: caps.UsagePlugin != nil, + CommandLinePlugin: caps.CommandLinePlugin != nil, + ManagementAPI: caps.ManagementAPI != nil, } } diff --git a/internal/pluginhost/rpc_schema_test.go b/internal/pluginhost/rpc_schema_test.go new file mode 100644 index 000000000..b48e9e7c6 --- /dev/null +++ b/internal/pluginhost/rpc_schema_test.go @@ -0,0 +1,40 @@ +package pluginhost + +import ( + "encoding/json" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +func TestRPCCapabilitiesIncludeFrontendAuthProviderExclusive(t *testing.T) { + plugin := pluginapi.Plugin{ + Capabilities: pluginapi.Capabilities{ + FrontendAuthProvider: frontendAuthProviderFunc{identifier: "exclusive-auth"}, + FrontendAuthProviderExclusive: true, + }, + } + + caps := rpcCapabilitiesFromPlugin(plugin) + if !caps.FrontendAuthProvider { + t.Fatal("FrontendAuthProvider = false, want true") + } + if !caps.FrontendAuthProviderExclusive { + t.Fatal("FrontendAuthProviderExclusive = false, want true") + } + + raw, errMarshal := json.Marshal(caps) + if errMarshal != nil { + t.Fatalf("Marshal() error = %v", errMarshal) + } + if !json.Valid(raw) { + t.Fatalf("marshaled capabilities are invalid JSON: %s", raw) + } + var decoded map[string]any + if errUnmarshal := json.Unmarshal(raw, &decoded); errUnmarshal != nil { + t.Fatalf("Unmarshal() error = %v", errUnmarshal) + } + if decoded["frontend_auth_provider_exclusive"] != true { + t.Fatalf("frontend_auth_provider_exclusive = %#v, want true", decoded["frontend_auth_provider_exclusive"]) + } +} diff --git a/sdk/access/registry.go b/sdk/access/registry.go index cbb0d1c55..e257f2765 100644 --- a/sdk/access/registry.go +++ b/sdk/access/registry.go @@ -21,9 +21,10 @@ type Result struct { } var ( - registryMu sync.RWMutex - registry = make(map[string]Provider) - order []string + registryMu sync.RWMutex + registry = make(map[string]Provider) + order []string + exclusiveProvider string ) // RegisterProvider registers a pre-built provider instance for a given type identifier. @@ -63,6 +64,21 @@ func UnregisterProvider(typ string) { registryMu.Unlock() } +// SetExclusiveProvider restricts RegisteredProviders to a single provider key when present. +func SetExclusiveProvider(typ string) { + normalizedType := strings.TrimSpace(typ) + registryMu.Lock() + exclusiveProvider = normalizedType + registryMu.Unlock() +} + +// ClearExclusiveProvider removes any active provider restriction. +func ClearExclusiveProvider() { + registryMu.Lock() + exclusiveProvider = "" + registryMu.Unlock() +} + // RegisteredProviders returns the global provider instances in registration order. func RegisteredProviders() []Provider { registryMu.RLock() @@ -70,6 +86,12 @@ func RegisteredProviders() []Provider { registryMu.RUnlock() return nil } + if exclusiveProvider != "" { + if provider, exists := registry[exclusiveProvider]; exists && provider != nil { + registryMu.RUnlock() + return []Provider{provider} + } + } providers := make([]Provider, 0, len(order)) for _, providerType := range order { provider, exists := registry[providerType] diff --git a/sdk/access/registry_test.go b/sdk/access/registry_test.go new file mode 100644 index 000000000..be21b971b --- /dev/null +++ b/sdk/access/registry_test.go @@ -0,0 +1,81 @@ +package access + +import ( + "context" + "net/http" + "testing" +) + +type testProvider struct { + id string +} + +func (p testProvider) Identifier() string { + return p.id +} + +func (p testProvider) Authenticate(context.Context, *http.Request) (*Result, *AuthError) { + return &Result{Provider: p.id, Principal: p.id}, nil +} + +func TestRegisteredProvidersReturnsOnlyExclusiveProvider(t *testing.T) { + UnregisterProvider("test-a") + UnregisterProvider("test-b") + ClearExclusiveProvider() + defer UnregisterProvider("test-a") + defer UnregisterProvider("test-b") + defer ClearExclusiveProvider() + + RegisterProvider("test-a", testProvider{id: "test-a"}) + RegisterProvider("test-b", testProvider{id: "test-b"}) + SetExclusiveProvider("test-b") + + providers := RegisteredProviders() + if len(providers) != 1 { + t.Fatalf("RegisteredProviders() len = %d, want 1", len(providers)) + } + if providers[0].Identifier() != "test-b" { + t.Fatalf("RegisteredProviders()[0] = %q, want test-b", providers[0].Identifier()) + } +} + +func TestRegisteredProvidersRestoresAllProvidersAfterExclusiveCleared(t *testing.T) { + UnregisterProvider("test-a") + UnregisterProvider("test-b") + ClearExclusiveProvider() + defer UnregisterProvider("test-a") + defer UnregisterProvider("test-b") + defer ClearExclusiveProvider() + + RegisterProvider("test-a", testProvider{id: "test-a"}) + RegisterProvider("test-b", testProvider{id: "test-b"}) + SetExclusiveProvider("test-b") + ClearExclusiveProvider() + + providers := RegisteredProviders() + if len(providers) != 2 { + t.Fatalf("RegisteredProviders() len = %d, want 2", len(providers)) + } + if providers[0].Identifier() != "test-a" || providers[1].Identifier() != "test-b" { + t.Fatalf("RegisteredProviders() = [%q, %q], want [test-a, test-b]", providers[0].Identifier(), providers[1].Identifier()) + } +} + +func TestRegisteredProvidersIgnoresStaleExclusiveProvider(t *testing.T) { + UnregisterProvider("test-a") + UnregisterProvider("missing") + ClearExclusiveProvider() + defer UnregisterProvider("test-a") + defer ClearExclusiveProvider() + + RegisterProvider("test-a", testProvider{id: "test-a"}) + SetExclusiveProvider("missing") + + providers := RegisteredProviders() + if len(providers) != 1 { + t.Fatalf("RegisteredProviders() len = %d, want 1", len(providers)) + } + if providers[0].Identifier() != "test-a" { + t.Fatalf("RegisteredProviders()[0] = %q, want test-a", providers[0].Identifier()) + } +} diff --git a/sdk/pluginapi/types.go b/sdk/pluginapi/types.go index c438b8fa5..308f04116 100644 --- a/sdk/pluginapi/types.go +++ b/sdk/pluginapi/types.go @@ -74,6 +74,8 @@ type Capabilities struct { AuthProvider AuthProvider // FrontendAuthProvider authenticates frontend requests before proxy handling. FrontendAuthProvider FrontendAuthProvider + // FrontendAuthProviderExclusive makes this frontend auth provider the only active request auth provider when selected. + FrontendAuthProviderExclusive bool // Executor sends requests to an upstream provider or local backend. Executor ProviderExecutor // ExecutorModelScope declares whether Executor serves static models, OAuth auth models, or both.