Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View file

@ -0,0 +1,18 @@
package pluginhost
import (
"context"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
)
const pluginHostABIVersion = pluginabi.ABIVersion
type pluginClient interface {
Call(ctx context.Context, method string, request []byte) ([]byte, error)
Shutdown()
}
type pluginLoader interface {
Open(file pluginFile, host *Host) (pluginClient, error)
}

View file

@ -0,0 +1,501 @@
package pluginhost
import (
"context"
"fmt"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
_ "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator/builtin"
log "github.com/sirupsen/logrus"
)
type registryModelInfo = registry.ModelInfo
type modelRegistry interface {
RegisterClient(clientID, clientProvider string, models []*registry.ModelInfo)
UnregisterClient(clientID string)
}
type modelProviderRegistry interface {
modelRegistry
GetModelProviders(modelID string) []string
}
type pluginModelRegistration struct {
pluginID string
provider string
priority int
models []*registry.ModelInfo
hasExecutor bool
}
func normalizedExecutorModelScope(caps pluginapi.Capabilities) pluginapi.ExecutorModelScope {
if caps.Executor == nil {
return pluginapi.ExecutorModelScopeBoth
}
switch caps.ExecutorModelScope {
case pluginapi.ExecutorModelScopeStatic, pluginapi.ExecutorModelScopeOAuth, pluginapi.ExecutorModelScopeBoth:
return caps.ExecutorModelScope
default:
return pluginapi.ExecutorModelScopeBoth
}
}
func executorScopeAllowsStaticModels(caps pluginapi.Capabilities) bool {
if caps.Executor == nil {
return true
}
scope := normalizedExecutorModelScope(caps)
return scope == pluginapi.ExecutorModelScopeStatic || scope == pluginapi.ExecutorModelScopeBoth
}
func executorScopeAllowsOAuthModels(caps pluginapi.Capabilities) bool {
if caps.Executor == nil {
return true
}
scope := normalizedExecutorModelScope(caps)
return scope == pluginapi.ExecutorModelScopeOAuth || scope == pluginapi.ExecutorModelScopeBoth
}
func normalizeExecutorFormats(raw []string) []sdktranslator.Format {
if len(raw) == 0 {
return nil
}
out := make([]sdktranslator.Format, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for _, item := range raw {
format := normalizeExecutorFormatName(item)
if format == "" {
continue
}
key := format.String()
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
out = append(out, format)
}
return out
}
func normalizeExecutorFormatName(raw string) sdktranslator.Format {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "none":
return ""
case "chat-completions", "chat_completions", "openai-chat-completions", "openai_chat_completions":
return sdktranslator.FormatOpenAI
case "responses", "openai-responses", "openai_responses":
return sdktranslator.FormatOpenAIResponse
case "anthropic":
return sdktranslator.FormatClaude
default:
return sdktranslator.FromString(strings.TrimSpace(raw))
}
}
func executorFormatContains(formats []sdktranslator.Format, target sdktranslator.Format) bool {
if target == "" {
return false
}
for _, format := range formats {
if format == target {
return true
}
}
return false
}
type AuthModelResult struct {
Provider string
Models []*registry.ModelInfo
Auth *coreauth.Auth
Handled bool
Err error
}
func pluginModelInfoToRegistryModelInfo(model pluginapi.ModelInfo) *registry.ModelInfo {
return &registry.ModelInfo{
ID: model.ID,
Object: model.Object,
Created: model.Created,
OwnedBy: model.OwnedBy,
Type: model.Type,
DisplayName: model.DisplayName,
Name: model.Name,
Version: model.Version,
Description: model.Description,
InputTokenLimit: int(model.InputTokenLimit),
OutputTokenLimit: int(model.OutputTokenLimit),
SupportedGenerationMethods: cloneStringSlice(model.SupportedGenerationMethods),
ContextLength: int(model.ContextLength),
MaxCompletionTokens: int(model.MaxCompletionTokens),
SupportedParameters: cloneStringSlice(model.SupportedParameters),
SupportedInputModalities: cloneStringSlice(model.SupportedInputModalities),
SupportedOutputModalities: cloneStringSlice(model.SupportedOutputModalities),
Thinking: pluginThinkingSupportToRegistryThinkingSupport(model.Thinking),
UserDefined: model.UserDefined,
}
}
func pluginThinkingSupportToRegistryThinkingSupport(thinking *pluginapi.ThinkingSupport) *registry.ThinkingSupport {
if thinking == nil {
return nil
}
return &registry.ThinkingSupport{
Min: thinking.Min,
Max: thinking.Max,
ZeroAllowed: thinking.ZeroAllowed,
DynamicAllowed: thinking.DynamicAllowed,
Levels: cloneStringSlice(thinking.Levels),
}
}
func registryModelInfoToPluginModelInfo(model *registry.ModelInfo) pluginapi.ModelInfo {
if model == nil {
return pluginapi.ModelInfo{}
}
return pluginapi.ModelInfo{
ID: model.ID,
Object: model.Object,
Created: model.Created,
OwnedBy: model.OwnedBy,
Type: model.Type,
DisplayName: model.DisplayName,
Name: model.Name,
Version: model.Version,
Description: model.Description,
InputTokenLimit: int64(model.InputTokenLimit),
OutputTokenLimit: int64(model.OutputTokenLimit),
SupportedGenerationMethods: cloneStringSlice(model.SupportedGenerationMethods),
ContextLength: int64(model.ContextLength),
MaxCompletionTokens: int64(model.MaxCompletionTokens),
SupportedParameters: cloneStringSlice(model.SupportedParameters),
SupportedInputModalities: cloneStringSlice(model.SupportedInputModalities),
SupportedOutputModalities: cloneStringSlice(model.SupportedOutputModalities),
Thinking: registryThinkingSupportToPluginThinkingSupport(model.Thinking),
UserDefined: model.UserDefined,
}
}
func registryThinkingSupportToPluginThinkingSupport(thinking *registry.ThinkingSupport) *pluginapi.ThinkingSupport {
if thinking == nil {
return nil
}
return &pluginapi.ThinkingSupport{
Min: thinking.Min,
Max: thinking.Max,
ZeroAllowed: thinking.ZeroAllowed,
DynamicAllowed: thinking.DynamicAllowed,
Levels: cloneStringSlice(thinking.Levels),
}
}
func cloneStringSlice(in []string) []string {
if len(in) == 0 {
return nil
}
return append([]string(nil), in...)
}
func cloneRegistryModels(in []*registry.ModelInfo) []*registry.ModelInfo {
if len(in) == 0 {
return nil
}
out := make([]*registry.ModelInfo, 0, len(in))
for _, model := range in {
if model == nil {
continue
}
copyModel := *model
copyModel.SupportedGenerationMethods = cloneStringSlice(model.SupportedGenerationMethods)
copyModel.SupportedParameters = cloneStringSlice(model.SupportedParameters)
copyModel.SupportedInputModalities = cloneStringSlice(model.SupportedInputModalities)
copyModel.SupportedOutputModalities = cloneStringSlice(model.SupportedOutputModalities)
if model.Thinking != nil {
thinking := *model.Thinking
thinking.Levels = cloneStringSlice(model.Thinking.Levels)
copyModel.Thinking = &thinking
}
out = append(out, &copyModel)
}
return out
}
func (h *Host) RegisterModels(ctx context.Context, modelRegistry modelRegistry) {
if h == nil || modelRegistry == nil {
return
}
snap := h.Snapshot()
records := h.activeRecordsFromSnapshot(snap)
registrations := make([]modelClientRegistration, 0)
nextClients := make(map[string]struct{})
nextProviders := make(map[string]string)
nextModelRegistrations := make(map[string]pluginModelRegistration)
for _, record := range records {
modelProvider := record.plugin.Capabilities.ModelProvider
registrar := record.plugin.Capabilities.ModelRegistrar
if modelProvider == nil && registrar == nil {
continue
}
if !executorScopeAllowsStaticModels(record.plugin.Capabilities) {
continue
}
var resp pluginapi.ModelRegistrationResponse
var errRegisterModels error
if modelProvider != nil {
modelResp, errStaticModels := h.callModelProviderStaticModels(ctx, record, modelProvider)
errRegisterModels = errStaticModels
resp = pluginapi.ModelRegistrationResponse{
Provider: modelResp.Provider,
Models: modelResp.Models,
}
} else {
resp, errRegisterModels = h.callModelRegistrar(ctx, record, registrar)
}
if errRegisterModels != nil {
log.Warnf("pluginhost: model registrar %s failed: %v", record.id, errRegisterModels)
continue
}
provider := strings.ToLower(strings.TrimSpace(resp.Provider))
if provider == "" || len(resp.Models) == 0 {
continue
}
models := make([]*registry.ModelInfo, 0, len(resp.Models))
for _, item := range resp.Models {
model := pluginModelInfoToRegistryModelInfo(item)
if model == nil || strings.TrimSpace(model.ID) == "" {
continue
}
model.ID = strings.TrimSpace(model.ID)
models = append(models, model)
}
if len(models) == 0 {
continue
}
nextModelRegistrations[record.id] = pluginModelRegistration{
pluginID: record.id,
provider: provider,
priority: record.priority,
models: cloneRegistryModels(models),
hasExecutor: record.plugin.Capabilities.Executor != nil,
}
nextProviders[record.id] = provider
if record.plugin.Capabilities.Executor == nil {
clientID := "plugin:" + record.id + ":" + provider
registrations = append(registrations, modelClientRegistration{
clientID: clientID,
provider: provider,
models: models,
})
nextClients[clientID] = struct{}{}
}
}
h.commitModelClients(snap, modelRegistry, registrations, nextClients, nextProviders, nextModelRegistrations)
}
func (h *Host) ModelsForAuth(ctx context.Context, auth *coreauth.Auth) AuthModelResult {
if h == nil || auth == nil {
return AuthModelResult{}
}
providerKey := normalizeProviderID(auth.Provider)
if providerKey == "" {
return AuthModelResult{}
}
for _, record := range h.activeRecords() {
modelProvider := record.plugin.Capabilities.ModelProvider
if modelProvider == nil || h.isPluginFused(record.id) {
continue
}
if !executorScopeAllowsOAuthModels(record.plugin.Capabilities) {
continue
}
authProvider := record.plugin.Capabilities.AuthProvider
if authProvider != nil {
identifier, okIdentifier := h.callAuthProviderIdentifier(record.id, authProvider)
if !okIdentifier || normalizeProviderID(identifier) != providerKey {
continue
}
} else {
recordProvider := normalizeProviderID(h.modelProvider(record.id))
if recordProvider == "" {
executor := record.plugin.Capabilities.Executor
if executor != nil {
candidate, okCandidate := h.executorProvider(record, executor)
if okCandidate {
recordProvider = candidate
}
}
}
if recordProvider != providerKey {
continue
}
}
resp, errModels := h.callModelsForAuth(ctx, record, modelProvider, auth)
if errModels != nil {
log.Warnf("pluginhost: models for auth %s failed: %v", auth.ID, errModels)
return AuthModelResult{Handled: true, Err: errModels}
}
respProvider := normalizeProviderID(resp.Provider)
if respProvider != "" && respProvider != providerKey {
continue
}
if respProvider == "" {
respProvider = providerKey
}
models := make([]*registry.ModelInfo, 0, len(resp.Models))
for _, item := range resp.Models {
model := pluginModelInfoToRegistryModelInfo(item)
if model != nil {
model.ID = strings.TrimSpace(model.ID)
}
if model != nil && model.ID != "" {
models = append(models, model)
}
}
path := ""
if auth.Attributes != nil {
path = auth.Attributes["path"]
}
var updated *coreauth.Auth
if authDataHasValue(resp.AuthUpdate) {
updated = h.AuthDataToCoreAuth(authDataWithDefaults(resp.AuthUpdate, auth), path, auth.FileName)
}
return AuthModelResult{Provider: respProvider, Models: models, Auth: updated, Handled: true}
}
return AuthModelResult{}
}
func authDataHasValue(data pluginapi.AuthData) bool {
return strings.TrimSpace(data.Provider) != "" ||
strings.TrimSpace(data.ID) != "" ||
strings.TrimSpace(data.FileName) != "" ||
strings.TrimSpace(data.Label) != "" ||
strings.TrimSpace(data.Prefix) != "" ||
strings.TrimSpace(data.ProxyURL) != "" ||
data.Disabled ||
len(data.StorageJSON) > 0 ||
len(data.Metadata) > 0 ||
len(data.Attributes) > 0 ||
!data.NextRefreshAfter.IsZero()
}
func authDataWithDefaults(data pluginapi.AuthData, auth *coreauth.Auth) pluginapi.AuthData {
if auth == nil {
return data
}
if strings.TrimSpace(data.Provider) == "" {
data.Provider = auth.Provider
}
if strings.TrimSpace(data.ID) == "" {
data.ID = auth.ID
}
if strings.TrimSpace(data.FileName) == "" {
data.FileName = auth.FileName
}
if strings.TrimSpace(data.Label) == "" {
data.Label = auth.Label
}
if strings.TrimSpace(data.Prefix) == "" {
data.Prefix = auth.Prefix
}
if strings.TrimSpace(data.ProxyURL) == "" {
data.ProxyURL = auth.ProxyURL
}
if len(data.Metadata) == 0 {
data.Metadata = cloneAnyMap(auth.Metadata)
} else {
metadata := cloneAnyMap(data.Metadata)
for key, value := range auth.Metadata {
if _, exists := metadata[key]; !exists {
metadata[key] = value
}
}
data.Metadata = metadata
}
if len(data.Attributes) == 0 {
data.Attributes = cloneStringMap(auth.Attributes)
} else {
attributes := cloneStringMap(data.Attributes)
for key, value := range auth.Attributes {
if _, exists := attributes[key]; !exists {
attributes[key] = value
}
}
data.Attributes = attributes
}
if len(data.StorageJSON) == 0 {
data.StorageJSON = storageJSONFromAuth(auth)
}
if data.NextRefreshAfter.IsZero() {
data.NextRefreshAfter = auth.NextRefreshAfter
}
return data
}
type modelClientRegistration struct {
clientID string
provider string
models []*registry.ModelInfo
}
func (h *Host) callModelRegistrar(ctx context.Context, record capabilityRecord, registrar pluginapi.ModelRegistrar) (resp pluginapi.ModelRegistrationResponse, err error) {
if h == nil || registrar == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.ModelRegistrationResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "ModelRegistrar.RegisterModels", recovered)
resp = pluginapi.ModelRegistrationResponse{}
err = fmt.Errorf("model registrar panic: %v", recovered)
}
}()
return registrar.RegisterModels(ctx, pluginapi.ModelRegistrationRequest{Plugin: record.meta})
}
func (h *Host) callModelProviderStaticModels(ctx context.Context, record capabilityRecord, provider pluginapi.ModelProvider) (resp pluginapi.ModelResponse, err error) {
if h == nil || provider == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.ModelResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "ModelProvider.StaticModels", recovered)
resp = pluginapi.ModelResponse{}
err = fmt.Errorf("model provider panic: %v", recovered)
}
}()
return provider.StaticModels(ctx, pluginapi.StaticModelRequest{
Plugin: record.meta,
Host: h.hostConfigSummary(),
})
}
func (h *Host) callModelsForAuth(ctx context.Context, record capabilityRecord, provider pluginapi.ModelProvider, auth *coreauth.Auth) (resp pluginapi.ModelResponse, err error) {
if h == nil || provider == nil || auth == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.ModelResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "ModelProvider.ModelsForAuth", recovered)
resp = pluginapi.ModelResponse{}
err = fmt.Errorf("model provider per-auth models panic: %v", recovered)
}
}()
return provider.ModelsForAuth(ctx, pluginapi.AuthModelRequest{
Plugin: record.meta,
AuthID: auth.ID,
AuthProvider: auth.Provider,
StorageJSON: storageJSONFromAuth(auth),
Metadata: cloneAnyMap(auth.Metadata),
Attributes: cloneStringMap(auth.Attributes),
Host: h.hostConfigSummary(),
HTTPClient: h.newHTTPClient(auth),
})
}

View file

@ -0,0 +1,149 @@
package pluginhost
import (
"bytes"
"context"
"net/http"
"strings"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func (h *Host) RegisterFrontendAuthProviders() {
if h == nil {
return
}
type exclusiveFrontendAuthCandidate struct {
key string
pluginID string
priority int
}
nextKeys := make(map[string]struct{})
var bestExclusive exclusiveFrontendAuthCandidate
for _, record := range h.activeRecords() {
provider := record.plugin.Capabilities.FrontendAuthProvider
if provider == nil || h.isPluginFused(record.id) {
continue
}
adapter := &accessAdapter{
host: h,
pluginID: record.id,
path: record.path,
version: record.version,
provider: provider,
}
key := strings.TrimSpace(adapter.Identifier())
if key == "" {
continue
}
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)
}
func (h *Host) pruneStaleAccessProviders(nextKeys map[string]struct{}) {
if h == nil {
return
}
staleKeys := make([]string, 0)
h.mu.Lock()
for key := range h.accessProviderKeys {
if _, okKey := nextKeys[key]; !okKey {
staleKeys = append(staleKeys, key)
}
}
h.accessProviderKeys = nextKeys
h.mu.Unlock()
for _, key := range staleKeys {
sdkaccess.UnregisterProvider(key)
}
}
type accessAdapter struct {
host *Host
pluginID string
path string
version string
provider pluginapi.FrontendAuthProvider
}
func (a *accessAdapter) Identifier() (identifier string) {
if a == nil || a.provider == nil {
return ""
}
defer func() {
if recovered := recover(); recovered != nil {
if a.host != nil {
a.host.fusePlugin(a.pluginID, "FrontendAuthProvider.Identifier", recovered)
}
identifier = ""
}
}()
pluginID := strings.TrimSpace(a.pluginID)
providerID := strings.TrimSpace(a.provider.Identifier())
if pluginID == "" || providerID == "" {
return ""
}
return "plugin:" + pluginID + ":" + providerID
}
func (a *accessAdapter) Authenticate(ctx context.Context, r *http.Request) (result *sdkaccess.Result, authErr *sdkaccess.AuthError) {
if a == nil || a.provider == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) {
return nil, sdkaccess.NewNotHandledError()
}
defer func() {
if recovered := recover(); recovered != nil {
a.host.fusePlugin(a.pluginID, "FrontendAuthProvider.Authenticate", recovered)
result = nil
authErr = sdkaccess.NewNotHandledError()
}
}()
body, errReadAll := readAndRestoreRequestBody(r)
if errReadAll != nil {
return nil, sdkaccess.NewInternalAuthError("failed to read plugin auth request body", errReadAll)
}
resp, errAuthenticate := a.provider.Authenticate(ctx, pluginapi.FrontendAuthRequest{
Method: r.Method,
Path: r.URL.Path,
Headers: cloneHeader(r.Header),
Query: cloneValues(r.URL.Query()),
Body: bytes.Clone(body),
})
if errAuthenticate != nil || !resp.Authenticated {
return nil, sdkaccess.NewNotHandledError()
}
providerID := a.Identifier()
if providerID == "" {
return nil, sdkaccess.NewNotHandledError()
}
return &sdkaccess.Result{
Provider: providerID,
Principal: resp.Principal,
Metadata: cloneStringMap(resp.Metadata),
}, nil
}

View file

@ -0,0 +1,948 @@
package pluginhost
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
type executorManager interface {
Executor(provider string) (coreauth.ProviderExecutor, bool)
RegisterExecutor(coreauth.ProviderExecutor)
UnregisterExecutor(provider string)
}
type executorRegistration struct {
provider string
adapter *executorAdapter
}
func (h *Host) RegisterExecutors(manager executorManager, modelRegistry modelProviderRegistry) {
if h == nil || manager == nil {
return
}
snap := h.Snapshot()
records := h.activeRecordsFromSnapshot(snap)
registrations := h.snapshotModelRegistrations()
selectedModels := make(map[string][]*registry.ModelInfo)
providerModels := make(map[string][]*registry.ModelInfo)
claimedModels := make(map[string]struct{})
claimedProviders := make(map[string]string)
for _, registration := range registrations {
if !registration.hasExecutor {
appendModelsForProvider(providerModels, registration.provider, registration.models)
}
}
for _, record := range records {
executor := record.plugin.Capabilities.Executor
if executor == nil || h.isPluginFused(record.id) {
continue
}
provider, okProvider := h.executorProvider(record, executor)
if !okProvider {
continue
}
registration := h.modelRegistration(record.id)
if h.providerHasNativeExecutor(manager, provider) {
appendModelsForProvider(providerModels, provider, registration.models)
continue
}
if len(registration.models) == 0 {
continue
}
if owner := claimedProviders[provider]; owner != "" && owner != record.id {
continue
}
for _, model := range registration.models {
modelID := strings.TrimSpace(model.ID)
if modelID == "" {
continue
}
if _, claimed := claimedModels[modelID]; claimed {
continue
}
if h.modelHasNativeExecutor(manager, modelRegistry, modelID) {
continue
}
claimedModels[modelID] = struct{}{}
claimedProviders[provider] = record.id
selectedModels[record.id] = append(selectedModels[record.id], model)
}
}
seenProviders := make(map[string]struct{})
nextProviders := make(map[string]struct{})
nextModelClients := make(map[string]struct{})
executorRegistrations := make([]executorRegistration, 0)
modelClientRegistrations := make([]modelClientRegistration, 0)
for _, record := range records {
executor := record.plugin.Capabilities.Executor
if executor == nil || h.isPluginFused(record.id) {
continue
}
provider, okProvider := h.executorProvider(record, executor)
if !okProvider {
continue
}
registration := h.modelRegistration(record.id)
if len(registration.models) > 0 && len(selectedModels[record.id]) == 0 {
continue
}
if _, seenProvider := seenProviders[provider]; seenProvider {
continue
}
seenProviders[provider] = struct{}{}
if h.providerHasNativeExecutor(manager, provider) {
continue
}
nextProviders[provider] = struct{}{}
executorRegistrations = append(executorRegistrations, newExecutorAdapterRegistration(h, record, provider, executor))
appendModelsForProvider(providerModels, provider, selectedModels[record.id])
if len(selectedModels[record.id]) > 0 {
clientID := pluginExecutorModelClientID(record.id, provider)
modelClientRegistrations = append(modelClientRegistrations, modelClientRegistration{
clientID: clientID,
provider: provider,
models: selectedModels[record.id],
})
nextModelClients[clientID] = struct{}{}
}
}
h.commitExecutorState(snap, manager, modelRegistry, providerModels, executorRegistrations, nextProviders, modelClientRegistrations, nextModelClients)
}
func pluginExecutorModelClientID(pluginID, provider string) string {
return "plugin:" + pluginID + ":" + provider + ":executor"
}
func (h *Host) commitExecutorState(snap *Snapshot, manager executorManager, modelRegistry modelRegistry, providerModels map[string][]*registry.ModelInfo, registrations []executorRegistration, nextProviders map[string]struct{}, modelClientRegistrations []modelClientRegistration, nextModelClients map[string]struct{}) {
if h == nil || manager == nil {
return
}
h.mu.Lock()
if h.Snapshot() != snap {
h.mu.Unlock()
return
}
h.providerModels = make(map[string][]*registryModelInfo, len(providerModels))
for provider, models := range providerModels {
h.providerModels[provider] = cloneRegistryModels(models)
}
staleProviders := make([]string, 0)
for provider := range h.executorProviders {
if _, okProvider := nextProviders[provider]; !okProvider {
staleProviders = append(staleProviders, provider)
}
}
h.executorProviders = nextProviders
if nextModelClients == nil {
nextModelClients = make(map[string]struct{})
}
staleModelClients := make([]string, 0)
for clientID := range h.executorModelClientIDs {
if _, okClient := nextModelClients[clientID]; !okClient {
staleModelClients = append(staleModelClients, clientID)
}
}
h.executorModelClientIDs = nextModelClients
for _, registration := range registrations {
if registration.adapter == nil || registration.provider == "" {
continue
}
manager.RegisterExecutor(registration.adapter)
}
for _, provider := range staleProviders {
existing, okExecutor := manager.Executor(provider)
if !okExecutor || !h.ownsExecutor(existing) {
continue
}
manager.UnregisterExecutor(provider)
}
h.mu.Unlock()
if modelRegistry == nil {
return
}
for _, registration := range modelClientRegistrations {
modelRegistry.RegisterClient(registration.clientID, registration.provider, registration.models)
}
for _, clientID := range staleModelClients {
modelRegistry.UnregisterClient(clientID)
}
}
func newExecutorAdapterRegistration(h *Host, record capabilityRecord, provider string, executor pluginapi.ProviderExecutor) executorRegistration {
return executorRegistration{
provider: provider,
adapter: &executorAdapter{
host: h,
pluginID: record.id,
path: record.path,
version: record.version,
provider: provider,
executor: executor,
inputFormats: normalizeExecutorFormats(record.plugin.Capabilities.ExecutorInputFormats),
outputFormats: normalizeExecutorFormats(record.plugin.Capabilities.ExecutorOutputFormats),
},
}
}
func (h *Host) snapshotModelRegistrations() []pluginModelRegistration {
if h == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
registrations := make([]pluginModelRegistration, 0, len(h.modelRegistrations))
for _, registration := range h.modelRegistrations {
registration.models = cloneRegistryModels(registration.models)
registrations = append(registrations, registration)
}
sort.SliceStable(registrations, func(i, j int) bool {
if registrations[i].priority == registrations[j].priority {
return registrations[i].pluginID < registrations[j].pluginID
}
return registrations[i].priority > registrations[j].priority
})
return registrations
}
func (h *Host) modelRegistration(pluginID string) pluginModelRegistration {
if h == nil {
return pluginModelRegistration{}
}
h.mu.Lock()
defer h.mu.Unlock()
registration := h.modelRegistrations[pluginID]
registration.models = cloneRegistryModels(registration.models)
return registration
}
func (h *Host) executorProvider(record capabilityRecord, executor pluginapi.ProviderExecutor) (string, bool) {
if h == nil || !h.recordCurrent(record) {
return "", false
}
provider := h.modelProvider(record.id)
if provider == "" {
identifier, okIdentifier := h.callExecutorIdentifier(record.id, executor)
if !okIdentifier {
return "", false
}
provider = identifier
}
provider = strings.ToLower(strings.TrimSpace(provider))
return provider, provider != ""
}
func (h *Host) callExecutorIdentifier(pluginID string, executor pluginapi.ProviderExecutor) (provider string, ok bool) {
if h == nil || executor == nil || h.isPluginFused(pluginID) {
return "", false
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(pluginID, "Executor.Identifier", recovered)
provider = ""
ok = false
}
}()
return executor.Identifier(), true
}
func (h *Host) providerHasNativeExecutor(manager executorManager, provider string) bool {
if h == nil || manager == nil {
return false
}
existing, okExecutor := manager.Executor(provider)
return okExecutor && existing != nil && !h.ownsExecutor(existing)
}
func (h *Host) modelHasNativeExecutor(manager executorManager, modelRegistry modelProviderRegistry, modelID string) bool {
if h == nil || manager == nil || modelRegistry == nil {
return false
}
for _, provider := range modelRegistry.GetModelProviders(modelID) {
if h.providerHasNativeExecutor(manager, provider) {
return true
}
}
return false
}
func appendModelsForProvider(out map[string][]*registry.ModelInfo, provider string, models []*registry.ModelInfo) {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" || len(models) == 0 {
return
}
seen := make(map[string]struct{}, len(out[provider])+len(models))
for _, model := range out[provider] {
if model != nil && strings.TrimSpace(model.ID) != "" {
seen[strings.TrimSpace(model.ID)] = struct{}{}
}
}
for _, model := range models {
if model == nil {
continue
}
modelID := strings.TrimSpace(model.ID)
if modelID == "" {
continue
}
if _, exists := seen[modelID]; exists {
continue
}
seen[modelID] = struct{}{}
out[provider] = append(out[provider], cloneRegistryModels([]*registry.ModelInfo{model})...)
}
}
func (h *Host) ModelsForProvider(provider string) []*registry.ModelInfo {
if h == nil {
return nil
}
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
return cloneRegistryModels(h.providerModels[provider])
}
func (h *Host) HasExecutorCandidateProvider(provider string) bool {
if h == nil {
return false
}
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return false
}
for _, record := range h.activeRecords() {
executor := record.plugin.Capabilities.Executor
if executor == nil || h.isPluginFused(record.id) {
continue
}
candidate, okCandidate := h.executorProvider(record, executor)
if okCandidate && candidate == provider {
return true
}
}
return false
}
// OwnsExecutor reports whether executor is an adapter managed by this host.
func (h *Host) OwnsExecutor(executor coreauth.ProviderExecutor) bool {
return h.ownsExecutor(executor)
}
func (h *Host) ownsExecutor(executor coreauth.ProviderExecutor) bool {
adapter, okAdapter := executor.(*executorAdapter)
return okAdapter && adapter != nil && adapter.host == h
}
func (h *Host) modelProvider(pluginID string) string {
if h == nil {
return ""
}
h.mu.Lock()
defer h.mu.Unlock()
return h.modelProviders[pluginID]
}
type executorAdapter struct {
host *Host
pluginID string
path string
version string
provider string
executor pluginapi.ProviderExecutor
inputFormats []sdktranslator.Format
outputFormats []sdktranslator.Format
}
func (a *executorAdapter) Identifier() string {
if a == nil {
return ""
}
return a.provider
}
type preparedExecutorCall struct {
req coreexecutor.Request
opts coreexecutor.Options
inputRequested sdktranslator.Format
requestedFormat sdktranslator.Format
inputFormat sdktranslator.Format
outputFormat sdktranslator.Format
}
func (a *executorAdapter) prepareExecutorCall(req coreexecutor.Request, opts coreexecutor.Options) (preparedExecutorCall, error) {
inputRequested := executorInputFormat(req, opts)
requestedFormat := executorRequestedFormat(req, opts)
inputFormat, errInput := a.selectExecutorInputFormat(inputRequested)
if errInput != nil {
return preparedExecutorCall{}, errInput
}
outputFormat, errOutput := a.selectExecutorOutputFormat(requestedFormat, inputFormat)
if errOutput != nil {
return preparedExecutorCall{}, errOutput
}
nativeReq := req
nativeOpts := opts
if inputRequested != "" && inputRequested != inputFormat {
nativeReq.Payload = sdktranslator.TranslateRequest(inputRequested, inputFormat, req.Model, req.Payload, opts.Stream)
}
nativeReq.Format = outputFormat
nativeOpts.SourceFormat = inputFormat
nativeOpts.ResponseFormat = outputFormat
return preparedExecutorCall{
req: nativeReq,
opts: nativeOpts,
inputRequested: inputRequested,
requestedFormat: requestedFormat,
inputFormat: inputFormat,
outputFormat: outputFormat,
}, nil
}
func (a *executorAdapter) RequestToFormat(req coreexecutor.Request, opts coreexecutor.Options) sdktranslator.Format {
if a == nil {
return ""
}
inputRequested := executorInputFormat(req, opts)
inputFormat, errInput := a.selectExecutorInputFormat(inputRequested)
if errInput != nil {
return ""
}
return inputFormat
}
func executorInputFormat(req coreexecutor.Request, opts coreexecutor.Options) sdktranslator.Format {
if opts.SourceFormat != "" {
return normalizeExecutorFormatName(opts.SourceFormat.String())
}
if req.Format != "" {
return normalizeExecutorFormatName(req.Format.String())
}
return sdktranslator.FormatOpenAI
}
func executorRequestedFormat(req coreexecutor.Request, opts coreexecutor.Options) sdktranslator.Format {
if format := coreexecutor.ResponseFormatOrSource(opts); format != "" {
return normalizeExecutorFormatName(format.String())
}
if req.Format != "" {
return normalizeExecutorFormatName(req.Format.String())
}
return sdktranslator.FormatOpenAI
}
func (a *executorAdapter) selectExecutorInputFormat(requested sdktranslator.Format) (sdktranslator.Format, error) {
if len(a.inputFormats) == 0 {
return "", fmt.Errorf("plugin executor %s declares no input formats", a.Identifier())
}
if executorFormatContains(a.inputFormats, requested) {
return requested, nil
}
for _, format := range a.inputFormats {
if requested == "" || sdktranslator.HasRequestTransformer(requested, format) {
return format, nil
}
}
return "", fmt.Errorf("plugin executor %s does not support input format %q", a.Identifier(), requested)
}
func (a *executorAdapter) selectExecutorOutputFormat(requested, inputFormat sdktranslator.Format) (sdktranslator.Format, error) {
if len(a.outputFormats) == 0 {
return "", fmt.Errorf("plugin executor %s declares no output formats", a.Identifier())
}
if executorFormatContains(a.outputFormats, requested) {
return requested, nil
}
if executorFormatContains(a.outputFormats, inputFormat) && a.executorResponseTranslationAvailable(inputFormat, requested) {
return inputFormat, nil
}
for _, format := range a.outputFormats {
if requested == "" || a.executorResponseTranslationAvailable(format, requested) {
return format, nil
}
}
return "", fmt.Errorf("plugin executor %s does not support output format %q", a.Identifier(), requested)
}
func (a *executorAdapter) executorResponseTranslationAvailable(from, to sdktranslator.Format) bool {
if from == "" || to == "" || from == to {
return true
}
if sdktranslator.HasResponseTransformer(to, from) {
return true
}
return a != nil && a.host.hasResponseTranslator()
}
func (h *Host) hasResponseTranslator() bool {
for _, record := range h.activeRecords() {
if h.isPluginFused(record.id) || record.plugin.Capabilities.ResponseTranslator == nil {
continue
}
return true
}
return false
}
func executorNativeStreamResponseTranslatorExists(from, to sdktranslator.Format) bool {
if from == "" || to == "" || from == to {
return true
}
return sdktranslator.HasStreamResponseTransformer(to, from)
}
func (a *executorAdapter) translateExecutorResponse(ctx context.Context, prepared preparedExecutorCall, payload []byte, stream bool, param *any) []byte {
if prepared.requestedFormat == "" || prepared.outputFormat == prepared.requestedFormat {
out := bytes.Clone(payload)
if prepared.requestedFormat == sdktranslator.FormatOpenAIResponse {
out = helps.EnsureResponsesUsageDetails(out)
}
return out
}
originalRequest := prepared.opts.OriginalRequest
if len(originalRequest) == 0 {
originalRequest = prepared.req.Payload
}
if stream {
frames := a.translateExecutorStreamPayload(ctx, prepared, payload, param)
if len(frames) == 0 {
return nil
}
if len(frames) == 1 {
return bytes.Clone(frames[0])
}
return bytes.Join(frames, nil)
}
out := sdktranslator.TranslateNonStream(ctx, prepared.outputFormat, prepared.requestedFormat, prepared.req.Model, originalRequest, prepared.req.Payload, payload, param)
if prepared.requestedFormat == sdktranslator.FormatOpenAIResponse {
out = helps.EnsureResponsesUsageDetails(out)
}
return out
}
func (a *executorAdapter) translateExecutorStreamChunks(ctx context.Context, prepared preparedExecutorCall, in <-chan pluginapi.ExecutorStreamChunk) <-chan pluginapi.ExecutorStreamChunk {
if prepared.requestedFormat == "" || (prepared.outputFormat == prepared.requestedFormat && prepared.requestedFormat != sdktranslator.FormatOpenAIResponse) {
return in
}
if in == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
out := make(chan pluginapi.ExecutorStreamChunk)
go func() {
defer close(out)
var param any
for {
select {
case <-ctx.Done():
return
case chunk, ok := <-in:
if !ok {
a.emitTranslatedExecutorStreamTail(ctx, prepared, out, &param)
return
}
if chunk.Err != nil {
_ = sendExecutorPluginStreamChunk(ctx, out, chunk)
continue
}
frames := a.translateExecutorStreamPayload(ctx, prepared, chunk.Payload, &param)
for _, frame := range frames {
if !sendExecutorPluginStreamChunk(ctx, out, pluginapi.ExecutorStreamChunk{Payload: frame}) {
return
}
}
}
}
}()
return out
}
func (a *executorAdapter) translateExecutorStreamPayload(ctx context.Context, prepared preparedExecutorCall, payload []byte, param *any) [][]byte {
if prepared.requestedFormat != "" && prepared.outputFormat == prepared.requestedFormat {
out := payload
if prepared.requestedFormat == sdktranslator.FormatOpenAIResponse {
out = helps.EnsureResponsesUsageDetails(out)
}
return [][]byte{out}
}
originalRequest := prepared.opts.OriginalRequest
if len(originalRequest) == 0 {
originalRequest = prepared.req.Payload
}
frames := sdktranslator.TranslateStream(ctx, prepared.outputFormat, prepared.requestedFormat, prepared.req.Model, originalRequest, prepared.req.Payload, payload, param)
if executorStreamTranslationFellBack(prepared, payload, frames) {
return nil
}
if prepared.requestedFormat == sdktranslator.FormatOpenAIResponse {
for i, frame := range frames {
frames[i] = helps.EnsureResponsesUsageDetails(frame)
}
}
return frames
}
func executorStreamTranslationFellBack(prepared preparedExecutorCall, payload []byte, frames [][]byte) bool {
if prepared.requestedFormat == "" || prepared.outputFormat == "" || prepared.outputFormat == prepared.requestedFormat {
return false
}
if len(frames) != 1 || !bytes.Equal(frames[0], payload) {
return false
}
// A plugin executor only reaches this path after host-side response translation
// has been selected. An unchanged single frame is the SDK registry fallback,
// not a valid translated frame to send to the client.
return executorNativeStreamResponseTranslatorExists(prepared.outputFormat, prepared.requestedFormat)
}
func (a *executorAdapter) emitTranslatedExecutorStreamTail(ctx context.Context, prepared preparedExecutorCall, out chan<- pluginapi.ExecutorStreamChunk, param *any) {
tail := executorStreamDonePayload(prepared.outputFormat)
if len(tail) == 0 {
return
}
frames := a.translateExecutorStreamPayload(ctx, prepared, tail, param)
for _, frame := range frames {
if !sendExecutorPluginStreamChunk(ctx, out, pluginapi.ExecutorStreamChunk{Payload: frame}) {
return
}
}
}
func executorStreamDonePayload(format sdktranslator.Format) []byte {
switch format {
case sdktranslator.FormatOpenAI:
return []byte("data: [DONE]")
default:
return nil
}
}
func sendExecutorPluginStreamChunk(ctx context.Context, out chan<- pluginapi.ExecutorStreamChunk, chunk pluginapi.ExecutorStreamChunk) bool {
select {
case out <- pluginapi.ExecutorStreamChunk{Payload: bytes.Clone(chunk.Payload), Err: chunk.Err}:
return true
case <-ctx.Done():
return false
}
}
func (a *executorAdapter) Execute(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (resp coreexecutor.Response, err error) {
if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) {
return coreexecutor.Response{}, fmt.Errorf("plugin executor %s is unavailable", a.Identifier())
}
defer func() {
if recovered := recover(); recovered != nil {
a.host.fusePlugin(a.pluginID, "Executor.Execute", recovered)
resp = coreexecutor.Response{}
err = fmt.Errorf("plugin executor %s panic: %v", a.Identifier(), recovered)
}
}()
prepared, errPrepare := a.prepareExecutorCall(req, opts)
if errPrepare != nil {
return coreexecutor.Response{}, errPrepare
}
pluginResp, errExecute := a.executor.Execute(ctx, buildExecutorRequest(a.host, a.provider, auth, prepared.req, prepared.opts))
if errExecute != nil {
return coreexecutor.Response{}, errExecute
}
return coreexecutor.Response{
Payload: a.translateExecutorResponse(ctx, prepared, pluginResp.Payload, false, nil),
Metadata: cloneAnyMap(pluginResp.Metadata),
Headers: cloneHeader(pluginResp.Headers),
}, nil
}
func (a *executorAdapter) ExecuteStream(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (result *coreexecutor.StreamResult, err error) {
if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) {
return nil, fmt.Errorf("plugin executor %s is unavailable", a.Identifier())
}
defer func() {
if recovered := recover(); recovered != nil {
a.host.fusePlugin(a.pluginID, "Executor.ExecuteStream", recovered)
result = nil
err = fmt.Errorf("plugin executor %s stream panic: %v", a.Identifier(), recovered)
}
}()
prepared, errPrepare := a.prepareExecutorCall(req, opts)
if errPrepare != nil {
return nil, errPrepare
}
pluginResp, errExecuteStream := a.executor.ExecuteStream(ctx, buildExecutorRequest(a.host, a.provider, auth, prepared.req, prepared.opts))
if errExecuteStream != nil {
return nil, errExecuteStream
}
return &coreexecutor.StreamResult{
Headers: cloneHeader(pluginResp.Headers),
Chunks: mapExecutorStreamChunks(ctx, a.translateExecutorStreamChunks(ctx, prepared, pluginResp.Chunks)),
}, nil
}
func (a *executorAdapter) Refresh(ctx context.Context, auth *coreauth.Auth) (refreshed *coreauth.Auth, err error) {
if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) {
return nil, fmt.Errorf("plugin executor %s is unavailable", a.Identifier())
}
record := a.host.authProviderRecord(authProvider(auth))
if record == nil || record.plugin.Capabilities.AuthProvider == nil {
return auth.Clone(), nil
}
defer func() {
if recovered := recover(); recovered != nil {
a.host.fusePlugin(record.id, "AuthProvider.RefreshAuth", recovered)
refreshed = nil
err = fmt.Errorf("plugin executor %s refresh panic: %v", a.Identifier(), recovered)
}
}()
pluginResp, errRefresh := record.plugin.Capabilities.AuthProvider.RefreshAuth(ctx, pluginapi.AuthRefreshRequest{
AuthID: authID(auth),
AuthProvider: authProvider(auth),
StorageJSON: storageJSONFromAuth(auth),
Metadata: cloneAnyMap(authMetadata(auth)),
Attributes: authAttributes(auth),
Host: a.host.hostConfigSummary(),
HTTPClient: a.host.newHTTPClient(auth),
})
if errRefresh != nil {
return nil, errRefresh
}
data := pluginResp.Auth
if strings.TrimSpace(data.Provider) == "" {
data.Provider = authProvider(auth)
}
if strings.TrimSpace(data.ID) == "" {
data.ID = authID(auth)
}
if strings.TrimSpace(data.FileName) == "" && auth != nil {
data.FileName = auth.FileName
}
if strings.TrimSpace(data.Label) == "" && auth != nil {
data.Label = auth.Label
}
if strings.TrimSpace(data.Prefix) == "" && auth != nil {
data.Prefix = auth.Prefix
}
if strings.TrimSpace(data.ProxyURL) == "" && auth != nil {
data.ProxyURL = auth.ProxyURL
}
if len(data.Metadata) == 0 && auth != nil {
data.Metadata = cloneAnyMap(auth.Metadata)
}
if len(data.Attributes) == 0 && auth != nil {
data.Attributes = cloneStringMap(auth.Attributes)
}
if len(data.StorageJSON) == 0 {
data.StorageJSON = storageJSONFromAuth(auth)
}
if pluginResp.NextRefreshAfter.IsZero() && auth != nil {
data.NextRefreshAfter = auth.NextRefreshAfter
}
if !pluginResp.NextRefreshAfter.IsZero() {
data.NextRefreshAfter = pluginResp.NextRefreshAfter
}
next := a.host.AuthDataToCoreAuth(data, "", data.FileName)
if next == nil {
return nil, fmt.Errorf("plugin executor %s refresh returned invalid auth data", a.Identifier())
}
if auth != nil {
next.CreatedAt = auth.CreatedAt
next.UpdatedAt = auth.UpdatedAt
}
return next, nil
}
func (a *executorAdapter) CountTokens(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (resp coreexecutor.Response, err error) {
if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) {
return coreexecutor.Response{}, fmt.Errorf("plugin executor %s is unavailable", a.Identifier())
}
defer func() {
if recovered := recover(); recovered != nil {
a.host.fusePlugin(a.pluginID, "Executor.CountTokens", recovered)
resp = coreexecutor.Response{}
err = fmt.Errorf("plugin executor %s count tokens panic: %v", a.Identifier(), recovered)
}
}()
prepared, errPrepare := a.prepareExecutorCall(req, opts)
if errPrepare != nil {
return coreexecutor.Response{}, errPrepare
}
pluginResp, errCountTokens := a.executor.CountTokens(ctx, buildExecutorRequest(a.host, a.provider, auth, prepared.req, prepared.opts))
if errCountTokens != nil {
return coreexecutor.Response{}, errCountTokens
}
return coreexecutor.Response{
Payload: a.translateExecutorResponse(ctx, prepared, pluginResp.Payload, false, nil),
Metadata: cloneAnyMap(pluginResp.Metadata),
Headers: cloneHeader(pluginResp.Headers),
}, nil
}
func (a *executorAdapter) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (resp *http.Response, err error) {
if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) {
return nil, fmt.Errorf("plugin executor %s is unavailable", a.Identifier())
}
if req == nil {
return nil, fmt.Errorf("plugin executor %s received nil HTTP request", a.Identifier())
}
defer func() {
if recovered := recover(); recovered != nil {
a.host.fusePlugin(a.pluginID, "Executor.HttpRequest", recovered)
resp = nil
err = fmt.Errorf("plugin executor %s http request panic: %v", a.Identifier(), recovered)
}
}()
body, errReadAll := readAndRestoreRequestBody(req)
if errReadAll != nil {
return nil, fmt.Errorf("read plugin http request body: %w", errReadAll)
}
pluginResp, errHTTPRequest := a.executor.HttpRequest(ctx, pluginapi.ExecutorHTTPRequest{
AuthID: authID(auth),
AuthProvider: authProvider(auth),
Method: req.Method,
URL: req.URL.String(),
Headers: cloneHeader(req.Header),
Body: bytes.Clone(body),
StorageJSON: storageJSONFromAuth(auth),
Metadata: cloneAnyMap(authMetadata(auth)),
Attributes: authAttributes(auth),
HTTPClient: a.host.newHTTPClient(auth, a.provider),
})
if errHTTPRequest != nil {
return nil, errHTTPRequest
}
status := pluginResp.StatusCode
if status == 0 {
status = http.StatusOK
}
resp = &http.Response{
StatusCode: status,
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
Header: cloneHeader(pluginResp.Headers),
Body: io.NopCloser(bytes.NewReader(bytes.Clone(pluginResp.Body))),
Request: req,
}
return resp, nil
}
func buildExecutorRequest(host *Host, provider string, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) pluginapi.ExecutorRequest {
return pluginapi.ExecutorRequest{
AuthID: authID(auth),
AuthProvider: authProvider(auth),
Model: req.Model,
Format: req.Format.String(),
Stream: opts.Stream,
Alt: opts.Alt,
Headers: cloneHeader(opts.Headers),
Query: cloneValues(opts.Query),
OriginalRequest: bytes.Clone(opts.OriginalRequest),
SourceFormat: opts.SourceFormat.String(),
Payload: bytes.Clone(req.Payload),
Metadata: mergeExecutorMetadata(req.Metadata, opts.Metadata),
StorageJSON: storageJSONFromAuth(auth),
AuthMetadata: cloneAnyMap(authMetadata(auth)),
AuthAttributes: authAttributes(auth),
HTTPClient: host.newHTTPClient(auth, provider),
}
}
func storageJSONFromAuth(auth *coreauth.Auth) []byte {
if auth == nil {
return nil
}
if rawProvider, okRaw := auth.Storage.(interface{ RawJSON() []byte }); okRaw {
return bytes.Clone(rawProvider.RawJSON())
}
if len(auth.Metadata) == 0 {
return nil
}
data, errMarshal := json.Marshal(auth.Metadata)
if errMarshal != nil {
return nil
}
return data
}
func authAttributes(auth *coreauth.Auth) map[string]string {
if auth == nil {
return nil
}
return cloneStringMap(auth.Attributes)
}
func mergeExecutorMetadata(reqMetadata, optsMetadata map[string]any) map[string]any {
if len(reqMetadata) == 0 && len(optsMetadata) == 0 {
return nil
}
merged := make(map[string]any, len(reqMetadata)+len(optsMetadata))
for key, value := range reqMetadata {
merged[key] = value
}
for key, value := range optsMetadata {
merged[key] = value
}
return merged
}
func mapExecutorStreamChunks(ctx context.Context, in <-chan pluginapi.ExecutorStreamChunk) <-chan coreexecutor.StreamChunk {
if ctx == nil {
ctx = context.Background()
}
out := make(chan coreexecutor.StreamChunk)
if in == nil {
close(out)
return out
}
go func() {
defer close(out)
for {
var mapped coreexecutor.StreamChunk
select {
case <-ctx.Done():
return
case chunk, ok := <-in:
if !ok {
return
}
mapped = coreexecutor.StreamChunk{
Payload: bytes.Clone(chunk.Payload),
Err: chunk.Err,
}
}
select {
case <-ctx.Done():
return
case out <- mapped:
}
}
}()
return out
}

View file

@ -0,0 +1,565 @@
package pluginhost
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
"reflect"
"strings"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
func (h *Host) callRequestInterceptor(ctx context.Context, record capabilityRecord, method string, call func(context.Context, pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error), req pluginapi.RequestInterceptRequest) (out pluginapi.RequestInterceptResponse, ok bool) {
if h == nil || call == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.RequestInterceptResponse{}, false
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, method, recovered)
out = pluginapi.RequestInterceptResponse{}
ok = false
}
}()
resp, errIntercept := call(ctx, req)
if errIntercept != nil {
log.Warnf("pluginhost: request interceptor %s failed: %v", record.id, errIntercept)
return pluginapi.RequestInterceptResponse{}, false
}
return resp, true
}
func (h *Host) callResponseInterceptor(ctx context.Context, record capabilityRecord, interceptor pluginapi.ResponseInterceptor, req pluginapi.ResponseInterceptRequest) (out pluginapi.ResponseInterceptResponse, ok bool) {
if h == nil || interceptor == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.ResponseInterceptResponse{}, false
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "ResponseInterceptor.InterceptResponse", recovered)
out = pluginapi.ResponseInterceptResponse{}
ok = false
}
}()
resp, errIntercept := interceptor.InterceptResponse(ctx, req)
if errIntercept != nil {
log.Warnf("pluginhost: response interceptor %s failed: %v", record.id, errIntercept)
return pluginapi.ResponseInterceptResponse{}, false
}
return resp, true
}
func (h *Host) callStreamChunkInterceptor(ctx context.Context, record capabilityRecord, interceptor pluginapi.StreamChunkInterceptor, req pluginapi.StreamChunkInterceptRequest) (out pluginapi.StreamChunkInterceptResponse, ok bool) {
if h == nil || interceptor == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.StreamChunkInterceptResponse{}, false
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "StreamChunkInterceptor.InterceptStreamChunk", recovered)
out = pluginapi.StreamChunkInterceptResponse{}
ok = false
}
}()
resp, errIntercept := interceptor.InterceptStreamChunk(ctx, req)
if errIntercept != nil {
log.Warnf("pluginhost: stream chunk interceptor %s failed: %v", record.id, errIntercept)
return pluginapi.StreamChunkInterceptResponse{}, false
}
return resp, true
}
func (h *Host) InterceptRequestBeforeAuth(ctx context.Context, req pluginapi.RequestInterceptRequest) pluginapi.RequestInterceptResponse {
return h.InterceptRequestBeforeAuthExcept(ctx, req, "")
}
func (h *Host) InterceptRequestBeforeAuthExcept(ctx context.Context, req pluginapi.RequestInterceptRequest, skipPluginID string) pluginapi.RequestInterceptResponse {
return h.interceptRequest(ctx, req, "RequestInterceptor.InterceptRequestBeforeAuth", func(interceptor pluginapi.RequestInterceptor, ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
return interceptor.InterceptRequestBeforeAuth(ctx, req)
}, skipPluginID)
}
func (h *Host) InterceptRequestAfterAuth(ctx context.Context, req pluginapi.RequestInterceptRequest) pluginapi.RequestInterceptResponse {
return h.InterceptRequestAfterAuthExcept(ctx, req, "")
}
func (h *Host) InterceptRequestAfterAuthExcept(ctx context.Context, req pluginapi.RequestInterceptRequest, skipPluginID string) pluginapi.RequestInterceptResponse {
return h.interceptRequest(ctx, req, "RequestInterceptor.InterceptRequestAfterAuth", func(interceptor pluginapi.RequestInterceptor, ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
return interceptor.InterceptRequestAfterAuth(ctx, req)
}, skipPluginID)
}
func (h *Host) interceptRequest(ctx context.Context, req pluginapi.RequestInterceptRequest, method string, invoke func(pluginapi.RequestInterceptor, context.Context, pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error), skipPluginID string) pluginapi.RequestInterceptResponse {
current := pluginapi.RequestInterceptResponse{
Headers: cloneHeader(req.Headers),
Body: bytes.Clone(req.Body),
}
skipPluginID = strings.TrimSpace(skipPluginID)
for _, record := range h.activeRecords() {
interceptor := record.plugin.Capabilities.RequestInterceptor
if h.isPluginFused(record.id) || interceptor == nil || record.id == skipPluginID {
continue
}
nextReq := req
nextReq.Headers = cloneHeader(current.Headers)
nextReq.Body = bytes.Clone(current.Body)
nextReq.Metadata = cloneInterceptorMetadata(req.Metadata)
if resp, ok := h.callRequestInterceptor(ctx, record, method, func(callCtx context.Context, callReq pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
return invoke(interceptor, callCtx, callReq)
}, nextReq); ok {
current.Headers = mergeHeaders(current.Headers, resp.Headers, resp.ClearHeaders)
if len(resp.Body) > 0 {
current.Body = bytes.Clone(resp.Body)
}
if resp.Terminate {
current.Terminate = true
current.StatusCode = resp.StatusCode
current.ResponseHeaders = cloneHeader(resp.ResponseHeaders)
current.ResponseBody = bytes.Clone(resp.ResponseBody)
break
}
}
}
return current
}
// CompleteRequest schedules terminal notifications without blocking response delivery.
func (h *Host) CompleteRequest(ctx context.Context, completion pluginapi.RequestCompletion) {
h.CompleteRequestExcept(ctx, completion, "")
}
// CompleteRequestExcept notifies lifecycle plugins except the plugin that initiated a nested host execution.
func (h *Host) CompleteRequestExcept(ctx context.Context, completion pluginapi.RequestCompletion, skipPluginID string) {
if h == nil {
return
}
if ctx == nil {
ctx = context.Background()
} else {
ctx = context.WithoutCancel(ctx)
}
skipPluginID = strings.TrimSpace(skipPluginID)
for _, record := range h.activeRecords() {
plugin := record.plugin.Capabilities.RequestLifecyclePlugin
if h.isPluginFused(record.id) || plugin == nil || record.id == skipPluginID || !h.recordCurrent(record) {
continue
}
next := completion
next.Metadata = cloneInterceptorMetadata(completion.Metadata)
go func(record capabilityRecord, plugin pluginapi.RequestLifecyclePlugin, completion pluginapi.RequestCompletion) {
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "RequestLifecyclePlugin.HandleRequestComplete", recovered)
}
}()
if errComplete := plugin.HandleRequestComplete(ctx, completion); errComplete != nil {
log.Warnf("pluginhost: request lifecycle plugin %s failed: %v", record.id, errComplete)
}
}(record, plugin, next)
}
}
func (h *Host) InterceptResponse(ctx context.Context, req pluginapi.ResponseInterceptRequest) pluginapi.ResponseInterceptResponse {
return h.InterceptResponseExcept(ctx, req, "")
}
func (h *Host) InterceptResponseExcept(ctx context.Context, req pluginapi.ResponseInterceptRequest, skipPluginID string) pluginapi.ResponseInterceptResponse {
current := pluginapi.ResponseInterceptResponse{
Headers: cloneHeader(req.ResponseHeaders),
Body: bytes.Clone(req.Body),
}
skipPluginID = strings.TrimSpace(skipPluginID)
for _, record := range h.activeRecords() {
interceptor := record.plugin.Capabilities.ResponseInterceptor
if h.isPluginFused(record.id) || interceptor == nil || record.id == skipPluginID {
continue
}
nextReq := req
nextReq.RequestHeaders = cloneHeader(req.RequestHeaders)
nextReq.ResponseHeaders = cloneHeader(current.Headers)
nextReq.OriginalRequest = bytes.Clone(req.OriginalRequest)
nextReq.RequestBody = bytes.Clone(req.RequestBody)
nextReq.Body = bytes.Clone(current.Body)
nextReq.Metadata = cloneInterceptorMetadata(req.Metadata)
if resp, ok := h.callResponseInterceptor(ctx, record, interceptor, nextReq); ok {
current.Headers = mergeHeaders(current.Headers, resp.Headers, resp.ClearHeaders)
if len(resp.Body) > 0 {
current.Body = bytes.Clone(resp.Body)
}
}
}
return current
}
func (h *Host) InterceptStreamChunk(ctx context.Context, req pluginapi.StreamChunkInterceptRequest) pluginapi.StreamChunkInterceptResponse {
return h.InterceptStreamChunkExcept(ctx, req, "")
}
func (h *Host) InterceptStreamChunkExcept(ctx context.Context, req pluginapi.StreamChunkInterceptRequest, skipPluginID string) pluginapi.StreamChunkInterceptResponse {
current := pluginapi.StreamChunkInterceptResponse{
Headers: cloneHeader(req.ResponseHeaders),
Body: bytes.Clone(req.Body),
}
skipPluginID = strings.TrimSpace(skipPluginID)
for _, record := range h.activeRecords() {
interceptor := record.plugin.Capabilities.StreamChunkInterceptor
if h.isPluginFused(record.id) || interceptor == nil || current.DropChunk || record.id == skipPluginID {
continue
}
nextReq := req
nextReq.RequestHeaders = cloneHeader(req.RequestHeaders)
nextReq.ResponseHeaders = cloneHeader(current.Headers)
// Schema v3+ omits request bodies on payload chunks to avoid re-sending multi-MB
// prompts across cgo/JSON for every frame. Legacy plugins still receive them.
if req.ChunkIndex != pluginapi.StreamChunkHeaderInitIndex && streamChunkOmitsRequestBodies(record.plugin.SchemaVersion) {
nextReq.OriginalRequest = nil
nextReq.RequestBody = nil
} else {
nextReq.OriginalRequest = bytes.Clone(req.OriginalRequest)
nextReq.RequestBody = bytes.Clone(req.RequestBody)
}
nextReq.Body = bytes.Clone(current.Body)
nextReq.HistoryChunks = cloneByteSlices(req.HistoryChunks)
nextReq.Metadata = cloneInterceptorMetadata(req.Metadata)
if resp, ok := h.callStreamChunkInterceptor(ctx, record, interceptor, nextReq); ok {
current.Headers = mergeHeaders(current.Headers, resp.Headers, resp.ClearHeaders)
if len(resp.Body) > 0 {
current.Body = bytes.Clone(resp.Body)
}
if resp.DropChunk {
current.DropChunk = true
}
}
}
return current
}
func (h *Host) HasStreamInterceptors() bool {
if h == nil {
return false
}
for _, record := range h.activeRecords() {
if h.isPluginFused(record.id) {
continue
}
if record.plugin.Capabilities.StreamChunkInterceptor != nil {
return true
}
}
return false
}
// StreamChunkPayloadIncludesRequestBody reports whether any active stream chunk
// interceptor still requires OriginalRequest/RequestBody on payload chunks
// (schema_version < SchemaVersionStreamChunkOmitRequestBody).
func (h *Host) StreamChunkPayloadIncludesRequestBody() bool {
if h == nil {
return false
}
for _, record := range h.activeRecords() {
if h.isPluginFused(record.id) || record.plugin.Capabilities.StreamChunkInterceptor == nil {
continue
}
if !streamChunkOmitsRequestBodies(record.plugin.SchemaVersion) {
return true
}
}
return false
}
func streamChunkOmitsRequestBodies(schemaVersion uint32) bool {
return schemaVersion >= pluginabi.SchemaVersionStreamChunkOmitRequestBody
}
func (h *Host) HasRequestInterceptors() bool {
if h == nil {
return false
}
for _, record := range h.activeRecords() {
if h.isPluginFused(record.id) {
continue
}
if record.plugin.Capabilities.RequestInterceptor != nil {
return true
}
}
return false
}
func (h *Host) commitModelClients(snap *Snapshot, modelRegistry modelRegistry, registrations []modelClientRegistration, nextClients map[string]struct{}, nextProviders map[string]string, nextModelRegistrations map[string]pluginModelRegistration) {
if h == nil || modelRegistry == nil {
return
}
staleClients := make([]string, 0)
h.mu.Lock()
if h.Snapshot() != snap {
h.mu.Unlock()
return
}
for clientID := range h.modelClientIDs {
if _, okClient := nextClients[clientID]; !okClient {
staleClients = append(staleClients, clientID)
}
}
h.modelClientIDs = nextClients
h.modelProviders = nextProviders
h.modelRegistrations = nextModelRegistrations
h.mu.Unlock()
for _, registration := range registrations {
modelRegistry.RegisterClient(registration.clientID, registration.provider, registration.models)
}
for _, clientID := range staleClients {
modelRegistry.UnregisterClient(clientID)
}
}
func readAndRestoreRequestBody(r *http.Request) ([]byte, error) {
if r == nil || r.Body == nil {
return nil, nil
}
body, errReadAll := io.ReadAll(r.Body)
if errReadAll != nil {
r.Body = io.NopCloser(bytes.NewReader(body))
return nil, errReadAll
}
r.Body = io.NopCloser(bytes.NewReader(body))
return body, nil
}
func authID(auth *coreauth.Auth) string {
if auth == nil {
return ""
}
return auth.ID
}
func authProvider(auth *coreauth.Auth) string {
if auth == nil {
return ""
}
return auth.Provider
}
func authMetadata(auth *coreauth.Auth) map[string]any {
if auth == nil {
return nil
}
return auth.Metadata
}
func cloneHeader(in http.Header) http.Header {
if len(in) == 0 {
return nil
}
out := make(http.Header, len(in))
for key, values := range in {
out[key] = append([]string(nil), values...)
}
return out
}
func mergeHeaders(current, updates http.Header, clear []string) http.Header {
out := cloneHeader(current)
if out == nil {
out = make(http.Header)
}
for _, key := range clear {
out.Del(key)
}
for key, values := range updates {
out.Del(key)
for _, value := range values {
out.Add(key, value)
}
}
return out
}
func cloneByteSlices(in [][]byte) [][]byte {
if len(in) == 0 {
return nil
}
out := make([][]byte, 0, len(in))
for _, item := range in {
out = append(out, bytes.Clone(item))
}
return out
}
func cloneValues(in url.Values) url.Values {
if len(in) == 0 {
return nil
}
out := make(url.Values, len(in))
for key, values := range in {
out[key] = append([]string(nil), values...)
}
return out
}
func cloneAnyMap(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
}
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func cloneInterceptorMetadata(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
}
visited := make(map[metadataCloneVisit]reflect.Value)
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = cloneInterceptorMetadataAny(reflect.ValueOf(value), visited)
}
return out
}
type metadataCloneVisit struct {
typ reflect.Type
ptr uintptr
}
func cloneInterceptorMetadataAny(value reflect.Value, visited map[metadataCloneVisit]reflect.Value) any {
cloned := cloneInterceptorMetadataReflectValue(value, visited)
if !cloned.IsValid() {
return nil
}
return cloned.Interface()
}
func cloneInterceptorMetadataReflectValue(value reflect.Value, visited map[metadataCloneVisit]reflect.Value) reflect.Value {
if !value.IsValid() {
return reflect.Value{}
}
switch value.Kind() {
case reflect.Interface:
if value.IsNil() {
return reflect.Zero(value.Type())
}
return cloneInterceptorMetadataReflectValue(value.Elem(), visited)
case reflect.Pointer:
if value.IsNil() {
return reflect.Zero(value.Type())
}
visit := metadataCloneVisit{typ: value.Type(), ptr: value.Pointer()}
if existing, okExisting := visited[visit]; okExisting {
return existing
}
out := reflect.New(value.Type().Elem())
visited[visit] = out
clonedElem := cloneInterceptorMetadataReflectValue(value.Elem(), visited)
if clonedElem.IsValid() {
outElem := out.Elem()
if clonedElem.Type().AssignableTo(outElem.Type()) {
outElem.Set(clonedElem)
} else if clonedElem.Type().ConvertibleTo(outElem.Type()) {
outElem.Set(clonedElem.Convert(outElem.Type()))
}
}
return out
case reflect.Map:
if value.IsNil() {
return reflect.Zero(value.Type())
}
visit := metadataCloneVisit{typ: value.Type(), ptr: value.Pointer()}
if existing, okExisting := visited[visit]; okExisting {
return existing
}
out := reflect.MakeMapWithSize(value.Type(), value.Len())
visited[visit] = out
iter := value.MapRange()
for iter.Next() {
keyValue := adaptClonedValue(iter.Key(), cloneInterceptorMetadataReflectValue(iter.Key(), visited))
valValue := adaptClonedValue(iter.Value(), cloneInterceptorMetadataReflectValue(iter.Value(), visited))
out.SetMapIndex(keyValue, valValue)
}
return out
case reflect.Slice:
if value.IsNil() {
return reflect.Zero(value.Type())
}
if value.Type().Elem().Kind() == reflect.Uint8 {
out := reflect.MakeSlice(value.Type(), value.Len(), value.Len())
reflect.Copy(out, value)
return out
}
visit := metadataCloneVisit{typ: value.Type(), ptr: value.Pointer()}
if existing, okExisting := visited[visit]; okExisting {
return existing
}
out := reflect.MakeSlice(value.Type(), value.Len(), value.Len())
visited[visit] = out
for i := 0; i < value.Len(); i++ {
clonedItem := cloneInterceptorMetadataReflectValue(value.Index(i), visited)
if !clonedItem.IsValid() {
continue
}
out.Index(i).Set(adaptClonedValue(value.Index(i), clonedItem))
}
return out
case reflect.Array:
out := reflect.New(value.Type()).Elem()
for i := 0; i < value.Len(); i++ {
clonedItem := cloneInterceptorMetadataReflectValue(value.Index(i), visited)
if !clonedItem.IsValid() {
continue
}
out.Index(i).Set(adaptClonedValue(value.Index(i), clonedItem))
}
return out
case reflect.Struct:
out := reflect.New(value.Type()).Elem()
// Preserve unexported fields and deep-clone exported fields on a best-effort basis.
out.Set(value)
for i := 0; i < value.NumField(); i++ {
field := value.Field(i)
if !out.Field(i).CanSet() {
continue
}
fieldClone := cloneInterceptorMetadataReflectValue(field, visited)
if !fieldClone.IsValid() {
continue
}
out.Field(i).Set(adaptClonedValue(field, fieldClone))
}
return out
default:
return value
}
}
func adaptClonedValue(original, cloned reflect.Value) reflect.Value {
if !cloned.IsValid() {
return original
}
if cloned.Type().AssignableTo(original.Type()) {
return cloned
}
if cloned.Type().ConvertibleTo(original.Type()) {
return cloned.Convert(original.Type())
}
return original
}
func cloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for key, value := range in {
out[key] = value
}
return out
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,369 @@
package pluginhost
import (
"bytes"
"context"
"fmt"
"runtime/debug"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
)
func (h *Host) RegisterUsagePlugins() {
if h == nil {
return
}
for _, record := range h.activeRecords() {
plugin := record.plugin.Capabilities.UsagePlugin
if plugin == nil || h.isPluginFused(record.id) {
continue
}
coreusage.RegisterNamedPlugin("plugin:"+record.id, &usageAdapter{
host: h,
pluginID: record.id,
plugin: plugin,
})
}
}
func (h *Host) refreshThinkingProviders(records []capabilityRecord) {
thinking.ClearPluginProviders()
if h == nil {
return
}
for _, record := range records {
applier := record.plugin.Capabilities.ThinkingApplier
if applier == nil || h.isPluginFused(record.id) {
continue
}
provider, okProvider := h.callThinkingIdentifier(record, applier)
if !okProvider {
continue
}
thinking.RegisterPluginProvider(record.id, provider, record.priority, &thinkingAdapter{
host: h,
pluginID: record.id,
path: record.path,
version: record.version,
provider: provider,
applier: applier,
})
}
}
func (h *Host) callThinkingIdentifier(record capabilityRecord, applier pluginapi.ThinkingApplier) (provider string, ok bool) {
if h == nil || applier == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return "", false
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "ThinkingApplier.Identifier", recovered)
provider = ""
ok = false
}
}()
provider = strings.ToLower(strings.TrimSpace(applier.Identifier()))
if provider == "" {
return "", false
}
return provider, true
}
func (h *Host) currentUsagePlugin(pluginID string) pluginapi.UsagePlugin {
if h == nil || strings.TrimSpace(pluginID) == "" {
return nil
}
for _, record := range h.activeRecords() {
if record.id != pluginID {
continue
}
if h.isPluginFused(record.id) {
return nil
}
return record.plugin.Capabilities.UsagePlugin
}
return nil
}
func (h *Host) fusePlugin(id, method string, recovered any) {
if h == nil {
return
}
h.mu.Lock()
h.fused[id] = fmt.Sprintf("%s panic: %v", method, recovered)
h.mu.Unlock()
thinking.UnregisterPluginProviders(id)
log.WithField("plugin_id", id).WithField("method", method).Errorf("pluginhost: plugin panic recovered: %v\n%s", recovered, debug.Stack())
}
func (h *Host) isPluginFused(id string) bool {
if h == nil {
return false
}
h.mu.Lock()
_, fused := h.fused[id]
h.mu.Unlock()
return fused
}
type usageAdapter struct {
host *Host
pluginID string
plugin pluginapi.UsagePlugin
}
type thinkingAdapter struct {
host *Host
pluginID string
path string
version string
provider string
applier pluginapi.ThinkingApplier
}
func (a *usageAdapter) HandleUsage(ctx context.Context, record coreusage.Record) {
if a == nil {
return
}
plugin := a.host.currentUsagePlugin(a.pluginID)
if plugin == nil {
return
}
defer func() {
if recovered := recover(); recovered != nil {
a.host.fusePlugin(a.pluginID, "UsagePlugin.HandleUsage", recovered)
}
}()
plugin.HandleUsage(ctx, pluginapi.UsageRecord{
Provider: record.Provider,
ExecutorType: record.ExecutorType,
Model: record.Model,
Alias: record.Alias,
APIKey: record.APIKey,
AuthID: record.AuthID,
AuthIndex: record.AuthIndex,
AuthType: record.AuthType,
Source: record.Source,
ReasoningEffort: record.ReasoningEffort,
ServiceTier: record.ServiceTier,
Generate: coreusage.GenerateEnabled(record.Generate),
RequestedAt: record.RequestedAt,
Latency: record.Latency,
TTFT: record.TTFT,
Failed: record.Failed,
Failure: pluginapi.UsageFailure{
StatusCode: record.Fail.StatusCode,
Body: record.Fail.Body,
},
Detail: pluginapi.UsageDetail{
InputTokens: record.Detail.InputTokens,
OutputTokens: record.Detail.OutputTokens,
ReasoningTokens: record.Detail.ReasoningTokens,
CachedTokens: record.Detail.CachedTokens,
CacheReadTokens: record.Detail.CacheReadTokens,
CacheCreationTokens: record.Detail.CacheCreationTokens,
TotalTokens: record.Detail.TotalTokens,
},
ResponseHeaders: cloneHeader(record.ResponseHeaders),
})
}
func (a *thinkingAdapter) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) (out []byte, err error) {
if a == nil || a.applier == nil || a.host == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) {
return bytes.Clone(body), nil
}
defer func() {
if recovered := recover(); recovered != nil {
a.host.fusePlugin(a.pluginID, "ThinkingApplier.ApplyThinking", recovered)
out = bytes.Clone(body)
err = nil
}
}()
resp, errApply := a.applier.ApplyThinking(context.Background(), pluginapi.ThinkingApplyRequest{
Provider: a.provider,
Model: registryModelInfoToPluginModelInfo(modelInfo),
Config: pluginapi.ThinkingConfig{
Mode: config.Mode.String(),
Budget: config.Budget,
Level: string(config.Level),
},
Body: bytes.Clone(body),
})
if errApply != nil || len(resp.Body) == 0 {
return bytes.Clone(body), nil
}
return bytes.Clone(resp.Body), nil
}
func (h *Host) NormalizeRequest(ctx context.Context, from, to sdktranslator.Format, model string, body []byte, stream bool) []byte {
current := bytes.Clone(body)
for _, record := range h.activeRecords() {
if h.isPluginFused(record.id) || record.plugin.Capabilities.RequestNormalizer == nil {
continue
}
if normalized, ok := h.callRequestNormalizer(ctx, record, from, to, model, current, stream); ok {
current = normalized
}
}
return current
}
func (h *Host) TranslateRequest(ctx context.Context, from, to sdktranslator.Format, model string, body []byte, stream bool) ([]byte, bool) {
for _, record := range h.activeRecords() {
if h.isPluginFused(record.id) || record.plugin.Capabilities.RequestTranslator == nil {
continue
}
if translated, ok := h.callRequestTranslator(ctx, record, from, to, model, body, stream); ok {
return translated, true
}
}
return bytes.Clone(body), false
}
func (h *Host) NormalizeResponseBefore(ctx context.Context, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) []byte {
current := bytes.Clone(body)
for _, record := range h.activeRecords() {
normalizer := record.plugin.Capabilities.ResponseBeforeTranslator
if h.isPluginFused(record.id) || normalizer == nil {
continue
}
if normalized, ok := h.callResponseNormalizer(ctx, record, "ResponseBeforeTranslator.NormalizeResponse", normalizer, from, to, model, originalRequestRawJSON, requestRawJSON, current, stream); ok {
current = normalized
}
}
return current
}
func (h *Host) TranslateResponse(ctx context.Context, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) ([]byte, bool) {
for _, record := range h.activeRecords() {
translator := record.plugin.Capabilities.ResponseTranslator
if h.isPluginFused(record.id) || translator == nil {
continue
}
if translated, ok := h.callResponseTranslator(ctx, record, translator, from, to, model, originalRequestRawJSON, requestRawJSON, body, stream); ok {
return translated, true
}
}
return bytes.Clone(body), false
}
func (h *Host) NormalizeResponseAfter(ctx context.Context, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) []byte {
current := bytes.Clone(body)
for _, record := range h.activeRecords() {
normalizer := record.plugin.Capabilities.ResponseAfterTranslator
if h.isPluginFused(record.id) || normalizer == nil {
continue
}
if normalized, ok := h.callResponseNormalizer(ctx, record, "ResponseAfterTranslator.NormalizeResponse", normalizer, from, to, model, originalRequestRawJSON, requestRawJSON, current, stream); ok {
current = normalized
}
}
return current
}
func (h *Host) callRequestNormalizer(ctx context.Context, record capabilityRecord, from, to sdktranslator.Format, model string, body []byte, stream bool) (out []byte, ok bool) {
if h == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) || record.plugin.Capabilities.RequestNormalizer == nil {
return nil, false
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "RequestNormalizer.NormalizeRequest", recovered)
out = nil
ok = false
}
}()
resp, errNormalizeRequest := record.plugin.Capabilities.RequestNormalizer.NormalizeRequest(ctx, pluginapi.RequestTransformRequest{
FromFormat: from.String(),
ToFormat: to.String(),
Model: model,
Stream: stream,
Body: bytes.Clone(body),
})
if errNormalizeRequest != nil || len(resp.Body) == 0 {
return nil, false
}
return bytes.Clone(resp.Body), true
}
func (h *Host) callRequestTranslator(ctx context.Context, record capabilityRecord, from, to sdktranslator.Format, model string, body []byte, stream bool) (out []byte, ok bool) {
if h == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) || record.plugin.Capabilities.RequestTranslator == nil {
return nil, false
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "RequestTranslator.TranslateRequest", recovered)
out = nil
ok = false
}
}()
resp, errTranslateRequest := record.plugin.Capabilities.RequestTranslator.TranslateRequest(ctx, pluginapi.RequestTransformRequest{
FromFormat: from.String(),
ToFormat: to.String(),
Model: model,
Stream: stream,
Body: bytes.Clone(body),
})
if errTranslateRequest != nil || len(resp.Body) == 0 {
return nil, false
}
return bytes.Clone(resp.Body), true
}
func (h *Host) callResponseNormalizer(ctx context.Context, record capabilityRecord, method string, normalizer pluginapi.ResponseNormalizer, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) (out []byte, ok bool) {
if h == nil || normalizer == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return nil, false
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, method, recovered)
out = nil
ok = false
}
}()
resp, errNormalizeResponse := normalizer.NormalizeResponse(ctx, pluginapi.ResponseTransformRequest{
FromFormat: from.String(),
ToFormat: to.String(),
Model: model,
Stream: stream,
OriginalRequest: bytes.Clone(originalRequestRawJSON),
TranslatedRequest: bytes.Clone(requestRawJSON),
Body: bytes.Clone(body),
})
if errNormalizeResponse != nil || len(resp.Body) == 0 {
return nil, false
}
return bytes.Clone(resp.Body), true
}
func (h *Host) callResponseTranslator(ctx context.Context, record capabilityRecord, translator pluginapi.ResponseTranslator, from, to sdktranslator.Format, model string, originalRequestRawJSON, requestRawJSON, body []byte, stream bool) (out []byte, ok bool) {
if h == nil || translator == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return nil, false
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "ResponseTranslator.TranslateResponse", recovered)
out = nil
ok = false
}
}()
resp, errTranslateResponse := translator.TranslateResponse(ctx, pluginapi.ResponseTransformRequest{
FromFormat: from.String(),
ToFormat: to.String(),
Model: model,
Stream: stream,
OriginalRequest: bytes.Clone(originalRequestRawJSON),
TranslatedRequest: bytes.Clone(requestRawJSON),
Body: bytes.Clone(body),
})
if errTranslateResponse != nil || len(resp.Body) == 0 {
return nil, false
}
return bytes.Clone(resp.Body), true
}

View file

@ -0,0 +1,652 @@
package pluginhost
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type rpcHostAuthGetRequest struct {
AuthIndex string `json:"auth_index"`
}
type rpcHostAuthListResponse struct {
Files []pluginapi.HostAuthFileEntry `json:"files"`
}
type rpcHostAuthGetResponse struct {
AuthIndex string `json:"auth_index"`
Name string `json:"name,omitempty"`
Path string `json:"path,omitempty"`
JSON json.RawMessage `json:"json"`
}
func (h *Host) SetAuthManager(manager *coreauth.Manager) {
if h == nil {
return
}
h.mu.Lock()
h.authManager = manager
h.mu.Unlock()
}
func (h *Host) currentAuthManager() *coreauth.Manager {
if h == nil {
return nil
}
h.mu.Lock()
manager := h.authManager
h.mu.Unlock()
return manager
}
func (h *Host) callHostAuthList(ctx context.Context, request []byte) ([]byte, error) {
_ = ctx
if len(bytesTrimSpace(request)) > 0 {
var req map[string]any
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host auth list request: %w", errUnmarshal)
}
}
entries, errList := h.listAuthFiles()
if errList != nil {
return nil, errList
}
return marshalRPCResult(rpcHostAuthListResponse{Files: entries})
}
func (h *Host) callHostAuthGet(ctx context.Context, request []byte) ([]byte, error) {
_ = ctx
var req rpcHostAuthGetRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host auth get request: %w", errUnmarshal)
}
authIndex := strings.TrimSpace(req.AuthIndex)
if authIndex == "" {
return nil, fmt.Errorf("auth_index is required")
}
auth, rawJSON, errGet := h.authPhysicalJSONByIndex(authIndex)
if errGet != nil {
return nil, errGet
}
name := strings.TrimSpace(auth.FileName)
if name == "" {
name = strings.TrimSpace(auth.ID)
}
path := strings.TrimSpace(authAttribute(auth, "path"))
return marshalRPCResult(rpcHostAuthGetResponse{
AuthIndex: authIndex,
Name: name,
Path: path,
JSON: json.RawMessage(rawJSON),
})
}
func (h *Host) callHostAuthGetRuntime(ctx context.Context, request []byte) ([]byte, error) {
_ = ctx
var req rpcHostAuthGetRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host auth get runtime request: %w", errUnmarshal)
}
authIndex := strings.TrimSpace(req.AuthIndex)
if authIndex == "" {
return nil, fmt.Errorf("auth_index is required")
}
auth, errGet := h.authByIndex(authIndex)
if errGet != nil {
return nil, errGet
}
entry := h.buildHostAuthFileEntry(auth)
if entry == nil {
return nil, fmt.Errorf("auth runtime info not found for auth_index %s", authIndex)
}
return marshalRPCResult(pluginapi.HostAuthGetRuntimeResponse{Auth: *entry})
}
func (h *Host) callHostAuthSave(ctx context.Context, request []byte) ([]byte, error) {
var req pluginapi.HostAuthSaveRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host auth save request: %w", errUnmarshal)
}
name, rawJSON, errValidate := validateHostAuthSaveRequest(req)
if errValidate != nil {
return nil, errValidate
}
path, errSave := h.saveAuthFile(ctx, name, rawJSON)
if errSave != nil {
return nil, errSave
}
return marshalRPCResult(pluginapi.HostAuthSaveResponse{
Name: name,
Path: path,
})
}
func (h *Host) listAuthFiles() ([]pluginapi.HostAuthFileEntry, error) {
manager := h.currentAuthManager()
if manager != nil {
auths := manager.List()
entries := make([]pluginapi.HostAuthFileEntry, 0, len(auths))
for _, auth := range auths {
if entry := h.buildHostAuthFileEntry(auth); entry != nil {
entries = append(entries, *entry)
}
}
sort.Slice(entries, func(i, j int) bool {
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
})
return entries, nil
}
return h.listAuthFilesFromDisk()
}
func (h *Host) listAuthFilesFromDisk() ([]pluginapi.HostAuthFileEntry, error) {
authDir := h.resolvedAuthDir()
if authDir == "" {
return nil, fmt.Errorf("auth directory is unavailable")
}
entries, errReadDir := os.ReadDir(authDir)
if errReadDir != nil {
return nil, fmt.Errorf("failed to read auth dir: %w", errReadDir)
}
files := make([]pluginapi.HostAuthFileEntry, 0)
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(strings.ToLower(name), ".json") {
continue
}
full := filepath.Join(authDir, name)
fileEntry := pluginapi.HostAuthFileEntry{
Name: name,
Source: "file",
Path: full,
}
if info, errInfo := entry.Info(); errInfo == nil {
fileEntry.Size = info.Size()
fileEntry.ModTime = info.ModTime()
}
if data, errRead := os.ReadFile(full); errRead == nil {
var metadata map[string]any
if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal == nil {
if provider, ok := metadata["type"].(string); ok {
fileEntry.Type = strings.TrimSpace(provider)
fileEntry.Provider = fileEntry.Type
}
if email, ok := metadata["email"].(string); ok {
fileEntry.Email = strings.TrimSpace(email)
}
if projectID, ok := metadata["project_id"].(string); ok {
fileEntry.ProjectID = strings.TrimSpace(projectID)
}
if rawPriority, ok := metadata["priority"]; ok {
if priority, okPriority := parsePriorityValue(rawPriority); okPriority {
fileEntry.Priority = priority
}
}
if note, ok := metadata["note"].(string); ok {
fileEntry.Note = strings.TrimSpace(note)
}
if websockets, okWebsockets := parseWebsocketsValue(metadata["websockets"]); okWebsockets {
fileEntry.Websockets = websockets
}
}
}
files = append(files, fileEntry)
}
sort.Slice(files, func(i, j int) bool {
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
})
return files, nil
}
func (h *Host) authByIndex(authIndex string) (*coreauth.Auth, error) {
authIndex = strings.TrimSpace(authIndex)
if authIndex == "" {
return nil, fmt.Errorf("auth_index is required")
}
manager := h.currentAuthManager()
if manager == nil {
return nil, fmt.Errorf("core auth manager unavailable")
}
for _, auth := range manager.List() {
if auth == nil {
continue
}
auth.EnsureIndex()
if auth.Index == authIndex {
return auth, nil
}
}
return nil, fmt.Errorf("auth not found for auth_index %s", authIndex)
}
func (h *Host) authPhysicalJSONByIndex(authIndex string) (*coreauth.Auth, []byte, error) {
auth, errGet := h.authByIndex(authIndex)
if errGet != nil {
return nil, nil, errGet
}
path := strings.TrimSpace(authAttribute(auth, "path"))
if path == "" {
return nil, nil, fmt.Errorf("auth file path not found for auth_index %s", authIndex)
}
data, errRead := os.ReadFile(path)
if errRead != nil {
if os.IsNotExist(errRead) {
return nil, nil, fmt.Errorf("auth file not found for auth_index %s", authIndex)
}
return nil, nil, fmt.Errorf("failed to read auth file: %w", errRead)
}
if len(bytesTrimSpace(data)) == 0 {
return nil, nil, fmt.Errorf("auth file is empty for auth_index %s", authIndex)
}
var metadata map[string]any
if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil {
return nil, nil, fmt.Errorf("invalid auth file for auth_index %s: %w", authIndex, errUnmarshal)
}
return auth, data, nil
}
func validateHostAuthSaveRequest(req pluginapi.HostAuthSaveRequest) (string, []byte, error) {
name := strings.TrimSpace(req.Name)
if isUnsafeAuthFileName(name) {
return "", nil, fmt.Errorf("invalid auth file name")
}
if !strings.HasSuffix(strings.ToLower(name), ".json") {
return "", nil, fmt.Errorf("auth file name must end with .json")
}
rawJSON := bytesTrimSpace(req.JSON)
if len(rawJSON) == 0 {
return "", nil, fmt.Errorf("json is required")
}
var metadata map[string]any
if errUnmarshal := json.Unmarshal(rawJSON, &metadata); errUnmarshal != nil {
return "", nil, fmt.Errorf("invalid auth json: %w", errUnmarshal)
}
return filepath.Base(name), rawJSON, nil
}
func (h *Host) saveAuthFile(ctx context.Context, name string, data []byte) (string, error) {
authDir := h.resolvedAuthDir()
if authDir == "" {
return "", fmt.Errorf("auth directory is unavailable")
}
dst := filepath.Join(authDir, filepath.Base(name))
if !filepath.IsAbs(dst) {
if abs, errAbs := filepath.Abs(dst); errAbs == nil {
dst = abs
}
}
auth, errBuild := h.buildAuthFromFileData(dst, data)
if errBuild != nil {
return "", errBuild
}
if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
return "", fmt.Errorf("failed to write auth file: %w", errWrite)
}
if errUpsert := h.upsertAuthRecord(ctx, auth); errUpsert != nil {
return "", errUpsert
}
return dst, nil
}
func (h *Host) buildAuthFromFileData(path string, data []byte) (*coreauth.Auth, error) {
if strings.TrimSpace(path) == "" {
return nil, fmt.Errorf("auth path is empty")
}
if data == nil {
var errRead error
data, errRead = os.ReadFile(path)
if errRead != nil {
return nil, fmt.Errorf("failed to read auth file: %w", errRead)
}
}
metadata := make(map[string]any)
if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil {
return nil, fmt.Errorf("invalid auth file: %w", errUnmarshal)
}
coreauth.NormalizeCredentialMetadata(metadata)
provider, _ := metadata["type"].(string)
if strings.TrimSpace(provider) == "" {
provider = "unknown"
}
label := provider
if email, ok := metadata["email"].(string); ok && strings.TrimSpace(email) != "" {
label = strings.TrimSpace(email)
}
authID := h.authIDForPath(path)
if authID == "" {
authID = path
}
auth := &coreauth.Auth{
ID: authID,
Provider: provider,
FileName: filepath.Base(path),
Label: label,
Status: coreauth.StatusActive,
Attributes: map[string]string{
"path": path,
"source": path,
},
Metadata: metadata,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if manager := h.currentAuthManager(); manager != nil {
if existing, ok := manager.GetByID(authID); ok {
auth.CreatedAt = existing.CreatedAt
auth.LastRefreshedAt = existing.LastRefreshedAt
auth.NextRetryAfter = existing.NextRetryAfter
auth.Runtime = existing.Runtime
}
}
if errWeight := coreauth.ValidateAuthWeight(auth); errWeight != nil {
return nil, fmt.Errorf("invalid auth weight: %w", errWeight)
}
coreauth.ApplyCustomHeadersFromMetadata(auth)
return auth, nil
}
func (h *Host) upsertAuthRecord(ctx context.Context, auth *coreauth.Auth) error {
manager := h.currentAuthManager()
if manager == nil || auth == nil {
return nil
}
if existing, ok := manager.GetByID(auth.ID); ok {
auth.CreatedAt = existing.CreatedAt
_, errUpdate := manager.Update(ctx, auth)
return errUpdate
}
_, errRegister := manager.Register(ctx, auth)
return errRegister
}
func isUnsafeAuthFileName(name string) bool {
if strings.TrimSpace(name) == "" {
return true
}
if strings.ContainsAny(name, "/\\") {
return true
}
if filepath.VolumeName(name) != "" {
return true
}
return false
}
func (h *Host) buildHostAuthFileEntry(auth *coreauth.Auth) *pluginapi.HostAuthFileEntry {
if auth == nil {
return nil
}
auth.EnsureIndex()
runtimeOnly := isRuntimeOnlyAuth(auth)
if runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled) {
return nil
}
path := strings.TrimSpace(authAttribute(auth, "path"))
if path == "" && !runtimeOnly {
return nil
}
name := strings.TrimSpace(auth.FileName)
if name == "" {
name = auth.ID
}
entry := &pluginapi.HostAuthFileEntry{
ID: auth.ID,
AuthIndex: auth.Index,
Name: name,
Type: strings.TrimSpace(auth.Provider),
Provider: strings.TrimSpace(auth.Provider),
Label: auth.Label,
Status: string(auth.Status),
StatusMessage: auth.StatusMessage,
Disabled: auth.Disabled,
Unavailable: auth.Unavailable,
RuntimeOnly: runtimeOnly,
Source: "memory",
Success: auth.Success,
Failed: auth.Failed,
RecentRequests: hostRecentRequests(auth),
}
if email := authEmail(auth); email != "" {
entry.Email = email
}
if projectID := authProjectID(auth); projectID != "" {
entry.ProjectID = projectID
}
if accountType, account := auth.AccountInfo(); accountType != "" || account != "" {
entry.AccountType = accountType
entry.Account = account
}
if !auth.CreatedAt.IsZero() {
entry.CreatedAt = auth.CreatedAt
}
if !auth.UpdatedAt.IsZero() {
entry.ModTime = auth.UpdatedAt
entry.UpdatedAt = auth.UpdatedAt
}
if !auth.LastRefreshedAt.IsZero() {
entry.LastRefresh = auth.LastRefreshedAt
}
if !auth.NextRetryAfter.IsZero() {
entry.NextRetryAfter = auth.NextRetryAfter
}
if path != "" {
entry.Path = path
entry.Source = "file"
if info, err := os.Stat(path); err == nil {
entry.Size = info.Size()
entry.ModTime = info.ModTime()
} else if os.IsNotExist(err) {
if !runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled || strings.EqualFold(strings.TrimSpace(auth.StatusMessage), "removed via management api")) {
return nil
}
entry.Source = "memory"
}
}
if p := strings.TrimSpace(authAttribute(auth, "priority")); p != "" {
if parsed, err := strconv.Atoi(p); err == nil {
entry.Priority = parsed
}
} else if auth.Metadata != nil {
if rawPriority, ok := auth.Metadata["priority"]; ok {
if priority, okPriority := parsePriorityValue(rawPriority); okPriority {
entry.Priority = priority
}
}
}
if note := strings.TrimSpace(authAttribute(auth, "note")); note != "" {
entry.Note = note
} else if auth.Metadata != nil {
if rawNote, ok := auth.Metadata["note"].(string); ok {
entry.Note = strings.TrimSpace(rawNote)
}
}
if websockets, ok := authWebsocketsValue(auth); ok {
entry.Websockets = websockets
}
return entry
}
func (h *Host) resolvedAuthDir() string {
if h == nil {
return ""
}
h.mu.Lock()
authDir := ""
if h.runtimeConfig != nil {
authDir = strings.TrimSpace(h.runtimeConfig.AuthDir)
}
h.mu.Unlock()
if authDir == "" {
return ""
}
authDir = filepath.Clean(authDir)
if !filepath.IsAbs(authDir) {
if abs, errAbs := filepath.Abs(authDir); errAbs == nil {
authDir = abs
}
}
return authDir
}
func (h *Host) authIDForPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
path = filepath.Clean(path)
if !filepath.IsAbs(path) {
if abs, errAbs := filepath.Abs(path); errAbs == nil {
path = abs
}
}
id := path
if authDir := h.resolvedAuthDir(); authDir != "" {
if rel, errRel := filepath.Rel(authDir, path); errRel == nil && rel != "" {
id = rel
}
}
if runtime.GOOS == "windows" {
id = strings.ToLower(id)
}
return id
}
func authEmail(auth *coreauth.Auth) string {
if auth == nil {
return ""
}
if auth.Metadata != nil {
if v, ok := auth.Metadata["email"].(string); ok {
return strings.TrimSpace(v)
}
}
if auth.Attributes != nil {
if v := strings.TrimSpace(auth.Attributes["email"]); v != "" {
return v
}
if v := strings.TrimSpace(auth.Attributes["account_email"]); v != "" {
return v
}
}
return ""
}
func authProjectID(auth *coreauth.Auth) string {
if auth == nil {
return ""
}
if auth.Metadata != nil {
if v, ok := auth.Metadata["project_id"].(string); ok {
if projectID := strings.TrimSpace(v); projectID != "" {
return projectID
}
}
}
if auth.Attributes != nil {
if projectID := strings.TrimSpace(auth.Attributes["project_id"]); projectID != "" {
return projectID
}
}
return ""
}
func authAttribute(auth *coreauth.Auth, key string) string {
if auth == nil || len(auth.Attributes) == 0 {
return ""
}
return auth.Attributes[key]
}
func isRuntimeOnlyAuth(auth *coreauth.Auth) bool {
if auth == nil || len(auth.Attributes) == 0 {
return false
}
return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true")
}
func authWebsocketsValue(auth *coreauth.Auth) (bool, bool) {
if auth == nil {
return false, false
}
if auth.Attributes != nil {
if raw := strings.TrimSpace(auth.Attributes["websockets"]); raw != "" {
parsed, errParse := strconv.ParseBool(raw)
if errParse == nil {
return parsed, true
}
}
}
if auth.Metadata == nil {
return false, false
}
return parseWebsocketsValue(auth.Metadata["websockets"])
}
func parsePriorityValue(raw any) (int, bool) {
switch v := raw.(type) {
case int:
return v, true
case int32:
return int(v), true
case int64:
return int(v), true
case float64:
return int(v), true
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err == nil {
return parsed, true
}
}
return 0, false
}
func parseWebsocketsValue(raw any) (bool, bool) {
switch v := raw.(type) {
case bool:
return v, true
case string:
parsed, errParse := strconv.ParseBool(strings.TrimSpace(v))
if errParse == nil {
return parsed, true
}
}
return false, false
}
func bytesTrimSpace(raw []byte) []byte {
return []byte(strings.TrimSpace(string(raw)))
}
func hostRecentRequests(auth *coreauth.Auth) []pluginapi.HostRecentRequestEntry {
if auth == nil {
return nil
}
snapshot := auth.RecentRequestsSnapshot(time.Now())
if len(snapshot) == 0 {
return nil
}
out := make([]pluginapi.HostRecentRequestEntry, 0, len(snapshot))
for _, entry := range snapshot {
out = append(out, pluginapi.HostRecentRequestEntry{
Time: entry.Time,
Success: entry.Success,
Failed: entry.Failed,
})
}
return out
}

View file

@ -0,0 +1,277 @@
package pluginhost
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type memoryAuthStorage struct {
payload []byte
}
func (s *memoryAuthStorage) RawJSON() []byte {
if s == nil {
return nil
}
return append([]byte(nil), s.payload...)
}
func (s *memoryAuthStorage) SaveTokenToFile(authFilePath string) error {
if s == nil || len(s.payload) == 0 {
return fmt.Errorf("memory auth storage payload is empty")
}
return os.WriteFile(authFilePath, s.payload, 0o600)
}
func TestHostAuthListCallbackUsesAuthManager(t *testing.T) {
authDir := t.TempDir()
path := filepath.Join(authDir, "demo-a.json")
if errWrite := os.WriteFile(path, []byte(`{"type":"demo","email":"a@example.com","api_key":"k1"}`), 0o600); errWrite != nil {
t.Fatalf("write auth file: %v", errWrite)
}
auth := &coreauth.Auth{
ID: "demo-a.json",
Provider: "demo",
FileName: "demo-a.json",
Label: "a@example.com",
Status: coreauth.StatusActive,
Attributes: map[string]string{
"path": path,
"source": path,
},
Metadata: map[string]any{
"type": "demo",
"email": "a@example.com",
"api_key": "k1",
},
Storage: &memoryAuthStorage{payload: []byte(`{"type":"demo","email":"a@example.com","api_key":"k1"}`)},
}
auth.EnsureIndex()
host := New()
host.runtimeConfig = &config.Config{AuthDir: authDir}
host.SetAuthManager(coreauth.NewManager(nil, nil, nil))
if _, errRegister := host.currentAuthManager().Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthList, nil)
if errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[rpcHostAuthListResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if len(resp.Files) != 1 {
t.Fatalf("files = %#v, want one entry", resp.Files)
}
entry := resp.Files[0]
if entry.AuthIndex != auth.Index || entry.Name != "demo-a.json" || entry.Email != "a@example.com" {
t.Fatalf("entry = %#v, want auth index and file metadata", entry)
}
}
func TestHostAuthGetCallbackReturnsPhysicalJSONByAuthIndex(t *testing.T) {
authDir := t.TempDir()
path := filepath.Join(authDir, "demo-b.json")
if errWrite := os.WriteFile(path, []byte(`{"type":"demo","email":"b@example.com","api_key":"k2"}`), 0o600); errWrite != nil {
t.Fatalf("write auth file: %v", errWrite)
}
auth := &coreauth.Auth{
ID: "demo-b.json",
Provider: "demo",
FileName: "demo-b.json",
Label: "b@example.com",
Status: coreauth.StatusActive,
Attributes: map[string]string{
"path": path,
"source": path,
},
Metadata: map[string]any{
"type": "demo",
"email": "b@example.com",
"api_key": "k2",
},
Storage: &memoryAuthStorage{payload: []byte(`{"type":"demo","email":"b@example.com","api_key":"changed"}`)},
}
auth.EnsureIndex()
host := New()
host.SetAuthManager(coreauth.NewManager(nil, nil, nil))
if _, errRegister := host.currentAuthManager().Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
req, errMarshal := json.Marshal(pluginapi.HostAuthGetRequest{AuthIndex: auth.Index})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthGet, req)
if errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[rpcHostAuthGetResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.AuthIndex != auth.Index || resp.Name != "demo-b.json" {
t.Fatalf("response = %#v, want auth index and name", resp)
}
var decoded map[string]any
if errUnmarshal := json.Unmarshal(resp.JSON, &decoded); errUnmarshal != nil {
t.Fatalf("unmarshal auth json: %v", errUnmarshal)
}
if decoded["email"] != "b@example.com" || decoded["api_key"] != "k2" {
t.Fatalf("decoded json = %#v, want credential payload", decoded)
}
}
func TestHostAuthListCallbackFallsBackToDisk(t *testing.T) {
authDir := t.TempDir()
path := filepath.Join(authDir, "claude-a.json")
if errWrite := os.WriteFile(path, []byte(`{"type":"claude","email":"c@example.com"}`), 0o600); errWrite != nil {
t.Fatalf("write auth file: %v", errWrite)
}
host := New()
host.runtimeConfig = &config.Config{AuthDir: authDir}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthList, nil)
if errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[rpcHostAuthListResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if len(resp.Files) != 1 {
t.Fatalf("files = %#v, want one disk entry", resp.Files)
}
entry := resp.Files[0]
if entry.Name != "claude-a.json" || entry.Type != "claude" || entry.Email != "c@example.com" {
t.Fatalf("entry = %#v, want disk metadata", entry)
}
if entry.ModTime.IsZero() {
t.Fatalf("entry modtime is zero: %#v", entry)
}
_ = time.Now()
}
func TestHostAuthGetRuntimeCallbackReturnsRuntimeInfo(t *testing.T) {
auth := &coreauth.Auth{
ID: "demo-runtime.json",
Provider: "demo",
FileName: "demo-runtime.json",
Label: "runtime@example.com",
Status: coreauth.StatusActive,
Attributes: map[string]string{
"runtime_only": "true",
},
Metadata: map[string]any{
"type": "demo",
"email": "runtime@example.com",
"api_key": "runtime-key",
},
Storage: &memoryAuthStorage{payload: []byte(`{"type":"demo","email":"runtime@example.com","api_key":"runtime-key"}`)},
}
auth.EnsureIndex()
host := New()
host.SetAuthManager(coreauth.NewManager(nil, nil, nil))
if _, errRegister := host.currentAuthManager().Register(context.Background(), auth); errRegister != nil {
t.Fatalf("register auth: %v", errRegister)
}
req, errMarshal := json.Marshal(pluginapi.HostAuthGetRequest{AuthIndex: auth.Index})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthGetRuntime, req)
if errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostAuthGetRuntimeResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.Auth.AuthIndex != auth.Index || resp.Auth.RuntimeOnly != true || resp.Auth.Email != "runtime@example.com" {
t.Fatalf("response = %#v, want runtime auth entry", resp.Auth)
}
}
func TestHostAuthSaveCallbackRejectsInvalidWeightBeforePersistence(t *testing.T) {
for _, rawWeight := range []string{`1.5`, `1000001`, `9223372036854775808`, `"invalid"`} {
t.Run(rawWeight, func(t *testing.T) {
authDir := t.TempDir()
host := New()
host.runtimeConfig = &config.Config{AuthDir: authDir}
host.SetAuthManager(coreauth.NewManager(nil, nil, nil))
req, errMarshal := json.Marshal(pluginapi.HostAuthSaveRequest{
Name: "invalid.json",
JSON: json.RawMessage(`{"type":"demo","weight":` + rawWeight + `}`),
})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
if _, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthSave, req); errCall == nil {
t.Fatal("host.auth.save accepted an invalid weight")
}
if _, errStat := os.Stat(filepath.Join(authDir, "invalid.json")); !os.IsNotExist(errStat) {
t.Fatalf("invalid auth file was persisted: %v", errStat)
}
if auths := host.currentAuthManager().List(); len(auths) != 0 {
t.Fatalf("invalid auth was registered: %#v", auths)
}
})
}
}
func TestHostAuthSaveCallbackWritesPhysicalFile(t *testing.T) {
authDir := t.TempDir()
host := New()
host.runtimeConfig = &config.Config{AuthDir: authDir}
host.SetAuthManager(coreauth.NewManager(nil, nil, nil))
req, errMarshal := json.Marshal(pluginapi.HostAuthSaveRequest{
Name: "saved.json",
JSON: json.RawMessage(`{"type":"demo","email":"saved@example.com","api_key":"saved-key"}`),
})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAuthSave, req)
if errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostAuthSaveResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.Name != "saved.json" {
t.Fatalf("response = %#v, want saved file name", resp)
}
data, errRead := os.ReadFile(resp.Path)
if errRead != nil {
t.Fatalf("read saved file: %v", errRead)
}
if string(data) != `{"type":"demo","email":"saved@example.com","api_key":"saved-key"}` {
t.Fatalf("saved file = %q, want credential json", string(data))
}
auths := host.currentAuthManager().List()
if len(auths) != 1 || auths[0].FileName != "saved.json" {
t.Fatalf("auths = %#v, want one registered auth", auths)
}
}

View file

@ -0,0 +1,599 @@
package pluginhost
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func (h *Host) hostConfigSummaryLocked() pluginapi.HostConfigSummary {
if h == nil || h.runtimeConfig == nil {
return pluginapi.HostConfigSummary{}
}
cfg := h.runtimeConfig
return pluginapi.HostConfigSummary{
AuthDir: strings.TrimSpace(cfg.AuthDir),
ProxyURL: strings.TrimSpace(cfg.ProxyURL),
ForceModelPrefix: cfg.ForceModelPrefix,
OAuthModelAlias: pluginOAuthModelAliases(cfg.OAuthModelAlias),
ExcludedModels: cloneStringSliceMap(cfg.OAuthExcludedModels),
}
}
func (h *Host) hostConfigSummary() pluginapi.HostConfigSummary {
if h == nil {
return pluginapi.HostConfigSummary{}
}
h.mu.Lock()
defer h.mu.Unlock()
return h.hostConfigSummaryLocked()
}
func pluginOAuthModelAliases(in map[string][]config.OAuthModelAlias) map[string][]pluginapi.ModelAlias {
if len(in) == 0 {
return nil
}
out := make(map[string][]pluginapi.ModelAlias, len(in))
for provider, aliases := range in {
key := normalizeProviderID(provider)
if key == "" {
continue
}
for _, alias := range aliases {
name := strings.TrimSpace(alias.Name)
value := strings.TrimSpace(alias.Alias)
if name == "" || value == "" {
continue
}
out[key] = append(out[key], pluginapi.ModelAlias{Name: name, Alias: value})
}
}
if len(out) == 0 {
return nil
}
return out
}
func cloneStringSliceMap(in map[string][]string) map[string][]string {
if len(in) == 0 {
return nil
}
out := make(map[string][]string, len(in))
for key, values := range in {
cleanKey := normalizeProviderID(key)
if cleanKey == "" {
continue
}
out[cleanKey] = cloneStringSlice(values)
}
if len(out) == 0 {
return nil
}
return out
}
func normalizeProviderID(provider string) string {
return strings.ToLower(strings.TrimSpace(provider))
}
func authIDForPath(path, authDir string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
id := path
if authDir = strings.TrimSpace(authDir); authDir != "" {
if rel, errRel := filepath.Rel(authDir, path); errRel == nil && rel != "" && !strings.HasPrefix(rel, "..") {
id = rel
}
}
id = filepath.ToSlash(filepath.Clean(id))
if runtime.GOOS == "windows" {
id = strings.ToLower(id)
}
return id
}
func (h *Host) AuthProviderIdentifiers() []string {
if h == nil {
return nil
}
out := make([]string, 0)
for _, record := range h.activeRecords() {
provider := record.plugin.Capabilities.AuthProvider
if provider == nil || h.isPluginFused(record.id) {
continue
}
identifier, okIdentifier := h.callAuthProviderIdentifier(record.id, provider)
if okIdentifier && identifier != "" {
out = append(out, identifier)
}
}
return out
}
func (h *Host) HasAuthProvider(provider string) bool {
return h.authProviderRecord(provider) != nil
}
func (h *Host) authProviderRecord(provider string) *capabilityRecord {
provider = normalizeProviderID(provider)
if h == nil || provider == "" {
return nil
}
for _, record := range h.activeRecords() {
authProvider := record.plugin.Capabilities.AuthProvider
if authProvider == nil || h.isPluginFused(record.id) {
continue
}
identifier, okIdentifier := h.callAuthProviderIdentifier(record.id, authProvider)
if okIdentifier && identifier == provider {
copyRecord := record
return &copyRecord
}
}
return nil
}
func (h *Host) callAuthProviderIdentifier(pluginID string, provider pluginapi.AuthProvider) (identifier string, ok bool) {
if h == nil || provider == nil || h.isPluginFused(pluginID) {
return "", false
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(pluginID, "AuthProvider.Identifier", recovered)
identifier = ""
ok = false
}
}()
return normalizeProviderID(provider.Identifier()), true
}
func (h *Host) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) {
auths, handled, errParseAuths := h.ParseAuths(ctx, req)
if errParseAuths != nil || !handled || len(auths) == 0 {
return nil, handled, errParseAuths
}
return auths[0], true, nil
}
func (h *Host) ParseAuths(ctx context.Context, req pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
if h == nil {
return nil, false, nil
}
if strings.TrimSpace(req.Provider) != "" {
record := h.authProviderRecord(req.Provider)
if record == nil {
return nil, false, nil
}
return h.callParseAuths(ctx, *record, req)
}
for _, record := range h.activeRecords() {
if record.plugin.Capabilities.AuthProvider == nil || h.isPluginFused(record.id) {
continue
}
auths, handled, errParse := h.callParseAuths(ctx, record, req)
if errParse != nil || handled {
return auths, handled, errParse
}
}
return nil, false, nil
}
func (h *Host) callParseAuth(ctx context.Context, record capabilityRecord, req pluginapi.AuthParseRequest) (auth *coreauth.Auth, handled bool, err error) {
auths, handled, errParseAuths := h.callParseAuths(ctx, record, req)
if errParseAuths != nil || !handled || len(auths) == 0 {
return nil, handled, errParseAuths
}
return auths[0], true, nil
}
func (h *Host) callParseAuths(ctx context.Context, record capabilityRecord, req pluginapi.AuthParseRequest) (auths []*coreauth.Auth, handled bool, err error) {
provider := record.plugin.Capabilities.AuthProvider
if h == nil || provider == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return nil, false, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "AuthProvider.ParseAuth", recovered)
auths = nil
handled = false
err = fmt.Errorf("auth provider panic: %v", recovered)
}
}()
if req.Host.AuthDir == "" {
req.Host = h.hostConfigSummary()
}
req.Provider = normalizeProviderID(req.Provider)
if req.Provider == "" {
req.Provider = normalizeProviderID(provider.Identifier())
}
req.RawJSON = bytes.Clone(req.RawJSON)
resp, errParse := provider.ParseAuth(ctx, req)
if errParse != nil {
return nil, false, errParse
}
if !resp.Handled {
return nil, false, nil
}
datas := pluginAuthParseResponseAuths(resp)
auths = make([]*coreauth.Auth, 0, len(datas))
for _, data := range datas {
if strings.TrimSpace(data.Provider) == "" {
data.Provider = req.Provider
}
if strings.TrimSpace(data.Provider) == "" {
data.Provider = normalizeProviderID(provider.Identifier())
}
if normalizeProviderID(data.Provider) == "" {
return nil, true, fmt.Errorf("auth provider %s returned auth without provider", record.id)
}
parsed := h.AuthDataToCoreAuth(data, req.Path, req.FileName)
if parsed == nil {
return nil, true, fmt.Errorf("auth provider %s returned invalid auth data", record.id)
}
auths = append(auths, parsed)
}
return auths, true, nil
}
func pluginAuthParseResponseAuths(resp pluginapi.AuthParseResponse) []pluginapi.AuthData {
if len(resp.Auths) > 0 {
return append([]pluginapi.AuthData(nil), resp.Auths...)
}
return []pluginapi.AuthData{resp.Auth}
}
func (h *Host) StartLogin(ctx context.Context, provider string, baseURL string) (pluginapi.AuthLoginStartResponse, bool, error) {
record := h.authProviderRecord(provider)
if record == nil {
return pluginapi.AuthLoginStartResponse{}, false, nil
}
return h.callStartLogin(ctx, *record, provider, baseURL)
}
func (h *Host) callStartLogin(ctx context.Context, record capabilityRecord, provider string, baseURL string) (resp pluginapi.AuthLoginStartResponse, handled bool, err error) {
authProvider := record.plugin.Capabilities.AuthProvider
if h == nil || authProvider == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.AuthLoginStartResponse{}, false, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "AuthProvider.StartLogin", recovered)
resp = pluginapi.AuthLoginStartResponse{}
handled = false
err = fmt.Errorf("auth provider start login panic: %v", recovered)
}
}()
req := pluginapi.AuthLoginStartRequest{
Provider: normalizeProviderID(provider),
BaseURL: strings.TrimSpace(baseURL),
Host: h.hostConfigSummary(),
HTTPClient: h.newHTTPClient(nil),
}
resp, errStart := authProvider.StartLogin(ctx, req)
if errStart != nil {
return pluginapi.AuthLoginStartResponse{}, true, errStart
}
return resp, true, nil
}
func (h *Host) PollLogin(ctx context.Context, provider, state string, metadata ...map[string]any) (pluginapi.AuthLoginPollResponse, bool, error) {
record := h.authProviderRecord(provider)
if record == nil {
return pluginapi.AuthLoginPollResponse{}, false, nil
}
var pollMetadata map[string]any
if len(metadata) > 0 {
pollMetadata = metadata[0]
}
return h.callPollLogin(ctx, *record, provider, state, pollMetadata)
}
func (h *Host) callPollLogin(ctx context.Context, record capabilityRecord, provider, state string, metadata map[string]any) (resp pluginapi.AuthLoginPollResponse, handled bool, err error) {
authProvider := record.plugin.Capabilities.AuthProvider
if h == nil || authProvider == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.AuthLoginPollResponse{}, false, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "AuthProvider.PollLogin", recovered)
resp = pluginapi.AuthLoginPollResponse{}
handled = false
err = fmt.Errorf("auth provider poll login panic: %v", recovered)
}
}()
req := pluginapi.AuthLoginPollRequest{
Provider: normalizeProviderID(provider),
State: strings.TrimSpace(state),
Host: h.hostConfigSummary(),
HTTPClient: h.newHTTPClient(nil),
Metadata: cloneAnyMap(metadata),
}
resp, errPoll := authProvider.PollLogin(ctx, req)
if errPoll != nil {
return pluginapi.AuthLoginPollResponse{}, true, errPoll
}
return resp, true, nil
}
func (h *Host) RefreshAuth(ctx context.Context, auth *coreauth.Auth) (refreshed *coreauth.Auth, handled bool, err error) {
if h == nil || auth == nil {
return nil, false, nil
}
record := h.authProviderRecord(authProvider(auth))
if record == nil || record.plugin.Capabilities.AuthProvider == nil {
return nil, false, nil
}
if !h.recordCurrent(*record) {
return nil, false, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "AuthProvider.RefreshAuth", recovered)
refreshed = nil
handled = true
err = fmt.Errorf("auth provider refresh panic: %v", recovered)
}
}()
pluginResp, errRefresh := record.plugin.Capabilities.AuthProvider.RefreshAuth(ctx, pluginapi.AuthRefreshRequest{
AuthID: authID(auth),
AuthProvider: authProvider(auth),
StorageJSON: storageJSONFromAuth(auth),
Metadata: cloneAnyMap(authMetadata(auth)),
Attributes: authAttributes(auth),
Host: h.hostConfigSummary(),
HTTPClient: h.newHTTPClient(auth),
})
if errRefresh != nil {
return nil, true, errRefresh
}
data := pluginResp.Auth
if strings.TrimSpace(data.Provider) == "" {
data.Provider = authProvider(auth)
}
if strings.TrimSpace(data.ID) == "" {
data.ID = authID(auth)
}
if strings.TrimSpace(data.FileName) == "" {
data.FileName = auth.FileName
}
if strings.TrimSpace(data.Label) == "" {
data.Label = auth.Label
}
if strings.TrimSpace(data.Prefix) == "" {
data.Prefix = auth.Prefix
}
if strings.TrimSpace(data.ProxyURL) == "" {
data.ProxyURL = auth.ProxyURL
}
if len(data.Metadata) == 0 {
data.Metadata = cloneAnyMap(auth.Metadata)
}
if len(data.Attributes) == 0 {
data.Attributes = cloneStringMap(auth.Attributes)
}
if len(data.StorageJSON) == 0 {
data.StorageJSON = storageJSONFromAuth(auth)
}
if pluginResp.NextRefreshAfter.IsZero() {
data.NextRefreshAfter = auth.NextRefreshAfter
} else {
data.NextRefreshAfter = pluginResp.NextRefreshAfter
}
next := h.AuthDataToCoreAuth(data, "", data.FileName)
if next == nil {
return nil, true, fmt.Errorf("auth provider refresh returned invalid auth data")
}
next.Index = auth.Index
next.CreatedAt = auth.CreatedAt
next.UpdatedAt = auth.UpdatedAt
return next, true, nil
}
func (h *Host) AuthDataToCoreAuth(data pluginapi.AuthData, path, fileName string) *coreauth.Auth {
authDir := ""
if h != nil {
authDir = h.hostConfigSummary().AuthDir
}
return pluginAuthDataToCoreAuth(data, path, fileName, authDir)
}
type pluginTokenStorage struct {
provider string
rawJSON []byte
meta map[string]any
}
func (s *pluginTokenStorage) SetMetadata(meta map[string]any) {
if s == nil {
return
}
s.meta = cloneAnyMap(meta)
}
func (s *pluginTokenStorage) RawJSON() []byte {
if s == nil {
return nil
}
payload, errPayload := mergedStorageJSON(s.rawJSON, s.meta, s.provider)
if errPayload != nil {
return nil
}
return payload
}
func (s *pluginTokenStorage) SaveTokenToFile(path string) error {
if s == nil {
return fmt.Errorf("plugin token storage is nil")
}
payload, errPayload := mergedStorageJSON(s.rawJSON, s.meta, s.provider)
if errPayload != nil {
return errPayload
}
if len(bytes.TrimSpace(payload)) == 0 {
return fmt.Errorf("plugin token storage payload is empty")
}
if pluginTokenStorageFileCurrent(path, payload) {
return nil
}
return atomicWriteFile(path, payload)
}
func pluginTokenStorageFileCurrent(path string, payload []byte) bool {
if strings.TrimSpace(path) == "" || len(bytes.TrimSpace(payload)) == 0 {
return false
}
current, errRead := os.ReadFile(path)
if errRead != nil {
return false
}
return jsonPayloadEqual(current, payload)
}
func jsonPayloadEqual(left, right []byte) bool {
var leftValue any
if errUnmarshalLeft := json.Unmarshal(left, &leftValue); errUnmarshalLeft != nil {
return false
}
var rightValue any
if errUnmarshalRight := json.Unmarshal(right, &rightValue); errUnmarshalRight != nil {
return false
}
return reflect.DeepEqual(leftValue, rightValue)
}
func mergedStorageJSON(raw []byte, metadata map[string]any, provider string) ([]byte, error) {
out := make(map[string]any)
if len(bytes.TrimSpace(raw)) > 0 {
if errUnmarshal := json.Unmarshal(raw, &out); errUnmarshal != nil {
return nil, fmt.Errorf("decode plugin token storage: %w", errUnmarshal)
}
if out == nil {
out = make(map[string]any)
}
}
for key, value := range metadata {
out[key] = value
}
provider = normalizeProviderID(provider)
if provider != "" {
out["type"] = provider
}
coreauth.NormalizeCredentialMetadata(out)
if len(out) == 0 {
return nil, fmt.Errorf("plugin token storage payload is empty")
}
payload, errMarshal := json.Marshal(out)
if errMarshal != nil {
return nil, fmt.Errorf("encode plugin token storage: %w", errMarshal)
}
return payload, nil
}
func atomicWriteFile(path string, data []byte) error {
path = strings.TrimSpace(path)
if path == "" {
return fmt.Errorf("path is empty")
}
dir := filepath.Dir(path)
if errMkdir := os.MkdirAll(dir, 0o700); errMkdir != nil {
return fmt.Errorf("create auth directory: %w", errMkdir)
}
tmp, errCreate := os.CreateTemp(dir, ".plugin-auth-*.tmp")
if errCreate != nil {
return fmt.Errorf("create temp auth file: %w", errCreate)
}
tmpPath := tmp.Name()
defer func() {
_ = os.Remove(tmpPath)
}()
if _, errWrite := tmp.Write(data); errWrite != nil {
if errClose := tmp.Close(); errClose != nil {
errWrite = fmt.Errorf("%w; close temp auth file: %v", errWrite, errClose)
}
return fmt.Errorf("write temp auth file: %w", errWrite)
}
if errClose := tmp.Close(); errClose != nil {
return fmt.Errorf("close temp auth file: %w", errClose)
}
if errRename := os.Rename(tmpPath, path); errRename != nil {
return fmt.Errorf("rename temp auth file: %w", errRename)
}
return nil
}
func pluginAuthDataToCoreAuth(data pluginapi.AuthData, path, fileName string, authDir string) *coreauth.Auth {
provider := normalizeProviderID(data.Provider)
if provider == "" {
return nil
}
metadata := cloneAnyMap(data.Metadata)
if metadata == nil {
metadata = make(map[string]any)
}
if provider != "" {
metadata["type"] = provider
}
attributes := cloneStringMap(data.Attributes)
if attributes == nil {
attributes = make(map[string]string)
}
path = strings.TrimSpace(path)
if path != "" {
attributes[coreauth.AttributePath] = path
attributes[coreauth.AttributeSource] = path
attributes[coreauth.AttributeSourceBackend] = coreauth.AuthSourceFile
}
fileName = strings.TrimSpace(firstNonEmpty(data.FileName, fileName))
if fileName != "" && attributes[coreauth.AttributeSource] == "" {
attributes[coreauth.AttributeSource] = fileName
}
id := strings.TrimSpace(data.ID)
if id == "" {
id = authIDForPath(firstNonEmpty(path, fileName), authDir)
}
status := coreauth.StatusActive
if data.Disabled {
status = coreauth.StatusDisabled
}
now := time.Now().UTC()
auth := &coreauth.Auth{
Provider: provider,
ID: id,
FileName: fileName,
Label: strings.TrimSpace(data.Label),
Prefix: strings.TrimSpace(data.Prefix),
ProxyURL: strings.TrimSpace(data.ProxyURL),
Disabled: data.Disabled,
Status: status,
Storage: &pluginTokenStorage{provider: provider, rawJSON: bytes.Clone(data.StorageJSON), meta: metadata},
Metadata: metadata,
Attributes: attributes,
CreatedAt: now,
UpdatedAt: now,
NextRefreshAfter: data.NextRefreshAfter,
}
return auth
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}

View file

@ -0,0 +1,482 @@
package pluginhost
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestAuthProviderDiscovery(t *testing.T) {
host := newHostWithRecords(
capabilityRecord{
id: "high",
priority: 20,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{identifier: " High-Provider "},
}},
},
capabilityRecord{
id: "low",
priority: 10,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{identifier: "low-provider"},
}},
},
capabilityRecord{
id: "missing-auth-provider",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
ModelRegistrar: staticModelRegistrar("provider", "model"),
}},
},
)
identifiers := host.AuthProviderIdentifiers()
if len(identifiers) != 2 || identifiers[0] != "high-provider" || identifiers[1] != "low-provider" {
t.Fatalf("AuthProviderIdentifiers() = %#v, want sorted normalized providers", identifiers)
}
if !host.HasAuthProvider(" HIGH-PROVIDER ") {
t.Fatal("HasAuthProvider(high-provider) = false, want true")
}
if host.HasAuthProvider("missing-provider") {
t.Fatal("HasAuthProvider(missing-provider) = true, want false")
}
}
func TestParseAuthDefaultsProviderFromRequest(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "auth-plugin",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{
identifier: "plugin-provider",
parseAuth: func(ctx context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
return pluginapi.AuthParseResponse{
Handled: true,
Auth: pluginapi.AuthData{
ID: "auth-1",
},
}, nil
},
},
},
},
})
auth, handled, errParse := host.ParseAuth(context.Background(), pluginapi.AuthParseRequest{Provider: "plugin-provider"})
if errParse != nil {
t.Fatalf("ParseAuth() error = %v", errParse)
}
if !handled || auth == nil {
t.Fatalf("ParseAuth() handled=%t auth=%#v, want parsed auth", handled, auth)
}
if auth.Provider != "plugin-provider" || auth.Metadata["type"] != "plugin-provider" {
t.Fatalf("ParseAuth() auth = %#v, want plugin-provider defaults", auth)
}
}
func TestParseAuthDefaultsProviderFromAuthProviderIdentifier(t *testing.T) {
seenProvider := ""
host := newHostWithRecords(capabilityRecord{
id: "auth-plugin",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{
identifier: "Plugin-Provider",
parseAuth: func(ctx context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
seenProvider = req.Provider
return pluginapi.AuthParseResponse{
Handled: true,
Auth: pluginapi.AuthData{
ID: "auth-1",
},
}, nil
},
},
},
},
})
auth, handled, errParse := host.ParseAuth(context.Background(), pluginapi.AuthParseRequest{})
if errParse != nil {
t.Fatalf("ParseAuth() error = %v", errParse)
}
if !handled || auth == nil {
t.Fatalf("ParseAuth() handled=%t auth=%#v, want parsed auth", handled, auth)
}
if seenProvider != "plugin-provider" {
t.Fatalf("plugin parse request provider = %q, want plugin-provider", seenProvider)
}
if auth.Provider != "plugin-provider" || auth.Metadata["type"] != "plugin-provider" {
t.Fatalf("ParseAuth() auth = %#v, want identifier provider fallback", auth)
}
}
func TestParseAuthsExpandsMultiplePluginAuths(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "geminicli",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{
identifier: "gemini-cli",
parseAuth: func(ctx context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
return pluginapi.AuthParseResponse{
Handled: true,
Auths: []pluginapi.AuthData{
{
Provider: "gemini-cli",
ID: "user.json",
FileName: "user.json",
StorageJSON: []byte(`{"type":"gemini-cli"}`),
},
{
Provider: "gemini-cli",
ID: "user-project-a.json",
FileName: "user-project-a.json",
StorageJSON: []byte(`{"type":"gemini-cli","project_id":"project-a"}`),
Metadata: map[string]any{"project_id": "project-a"},
},
},
}, nil
},
},
},
},
})
host.runtimeConfig = &config.Config{AuthDir: t.TempDir()}
auths, handled, errParse := host.ParseAuths(context.Background(), pluginapi.AuthParseRequest{Provider: "gemini-cli"})
if errParse != nil {
t.Fatalf("ParseAuths() error = %v", errParse)
}
if !handled || len(auths) != 2 {
t.Fatalf("ParseAuths() handled=%t len=%d, want two auths", handled, len(auths))
}
if auths[1].Provider != "gemini-cli" || auths[1].Metadata["project_id"] != "project-a" {
t.Fatalf("second auth = %#v, want project-a virtual auth", auths[1])
}
}
func TestStartLoginPassesProviderBaseURLHostAndHTTPClient(t *testing.T) {
authDir := t.TempDir()
expiresAt := time.Now().Add(time.Minute).UTC()
called := false
host := newHostWithRecords(capabilityRecord{
id: "auth-plugin",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{
identifier: "plugin-provider",
startLogin: func(ctx context.Context, req pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) {
called = true
if req.Provider != "plugin-provider" || req.BaseURL != "http://localhost:8080/login" {
t.Fatalf("StartLogin request = %#v, want provider/baseURL", req)
}
if req.Host.AuthDir != authDir || req.Host.ProxyURL != "http://proxy.local" || !req.Host.ForceModelPrefix {
t.Fatalf("StartLogin host = %#v, want configured summary", req.Host)
}
if req.HTTPClient == nil {
t.Fatal("StartLogin HTTPClient = nil, want host HTTP bridge")
}
return pluginapi.AuthLoginStartResponse{
Provider: req.Provider,
URL: "http://provider/login",
State: "state-1",
ExpiresAt: expiresAt,
}, nil
},
},
},
},
})
host.runtimeConfig = &config.Config{
SDKConfig: config.SDKConfig{
ProxyURL: "http://proxy.local",
ForceModelPrefix: true,
},
AuthDir: authDir,
}
resp, handled, errStart := host.StartLogin(context.Background(), " Plugin-Provider ", "http://localhost:8080/login")
if errStart != nil {
t.Fatalf("StartLogin() error = %v", errStart)
}
if !handled || !called {
t.Fatalf("StartLogin() handled=%t called=%t, want handled call", handled, called)
}
if resp.Provider != "plugin-provider" || resp.URL != "http://provider/login" || resp.State != "state-1" || !resp.ExpiresAt.Equal(expiresAt) {
t.Fatalf("StartLogin() response = %#v, want plugin response", resp)
}
}
func TestPollLoginPassesProviderStateHostAndHTTPClient(t *testing.T) {
authDir := t.TempDir()
called := false
host := newHostWithRecords(capabilityRecord{
id: "auth-plugin",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{
identifier: "plugin-provider",
pollLogin: func(ctx context.Context, req pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) {
called = true
if req.Provider != "plugin-provider" || req.State != "state-1" {
t.Fatalf("PollLogin request = %#v, want provider/state", req)
}
if req.Host.AuthDir != authDir || req.Host.ProxyURL != "http://proxy.local" || !req.Host.ForceModelPrefix {
t.Fatalf("PollLogin host = %#v, want configured summary", req.Host)
}
if req.HTTPClient == nil {
t.Fatal("PollLogin HTTPClient = nil, want host HTTP bridge")
}
return pluginapi.AuthLoginPollResponse{
Status: pluginapi.AuthLoginStatusSuccess,
Message: "done",
Auth: pluginapi.AuthData{
Provider: "plugin-provider",
ID: "auth-1",
},
}, nil
},
},
},
},
})
host.runtimeConfig = &config.Config{
SDKConfig: config.SDKConfig{
ProxyURL: "http://proxy.local",
ForceModelPrefix: true,
},
AuthDir: authDir,
}
resp, handled, errPoll := host.PollLogin(context.Background(), " Plugin-Provider ", " state-1 ")
if errPoll != nil {
t.Fatalf("PollLogin() error = %v", errPoll)
}
if !handled || !called {
t.Fatalf("PollLogin() handled=%t called=%t, want handled call", handled, called)
}
if resp.Status != pluginapi.AuthLoginStatusSuccess || resp.Message != "done" || resp.Auth.ID != "auth-1" {
t.Fatalf("PollLogin() response = %#v, want plugin response", resp)
}
}
func TestRefreshAuthPreservesAuthIndex(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "auth-plugin",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{
identifier: "plugin-provider",
refreshAuth: func(ctx context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
if req.AuthID != "auth-1" || req.AuthProvider != "plugin-provider" {
t.Fatalf("RefreshAuth request = %#v, want auth id/provider", req)
}
return pluginapi.AuthRefreshResponse{
Auth: pluginapi.AuthData{
Metadata: map[string]any{"access_token": "new-token"},
},
}, nil
},
},
},
},
})
auth := host.AuthDataToCoreAuth(pluginapi.AuthData{
Provider: "plugin-provider",
ID: "auth-1",
Metadata: map[string]any{"access_token": "old-token"},
}, "", "")
if auth == nil {
t.Fatal("AuthDataToCoreAuth() = nil, want auth")
}
auth.Index = "home-index-1"
refreshed, handled, errRefresh := host.RefreshAuth(context.Background(), auth)
if errRefresh != nil {
t.Fatalf("RefreshAuth() error = %v", errRefresh)
}
if !handled || refreshed == nil {
t.Fatalf("RefreshAuth() handled=%t auth=%#v, want refreshed auth", handled, refreshed)
}
if refreshed.Index != "home-index-1" {
t.Fatalf("RefreshAuth() index = %q, want home-index-1", refreshed.Index)
}
if got := refreshed.Metadata["access_token"]; got != "new-token" {
t.Fatalf("RefreshAuth() access_token = %q, want new-token", got)
}
}
func TestHostAuthDataToCoreAuthRejectsMissingProviderAndUsesAuthDir(t *testing.T) {
authDir := t.TempDir()
host := New()
host.runtimeConfig = &config.Config{AuthDir: authDir}
path := filepath.Join(authDir, "nested", "auth.json")
if auth := host.AuthDataToCoreAuth(pluginapi.AuthData{ID: "auth-1"}, path, "auth.json"); auth != nil {
t.Fatalf("AuthDataToCoreAuth() = %#v, want nil for missing provider", auth)
}
auth := host.AuthDataToCoreAuth(pluginapi.AuthData{Provider: "Plugin-Provider"}, path, "")
if auth == nil {
t.Fatal("AuthDataToCoreAuth() = nil, want auth")
}
if auth.Provider != "plugin-provider" || auth.ID != "nested/auth.json" {
t.Fatalf("AuthDataToCoreAuth() auth = %#v, want normalized provider and relative ID", auth)
}
if auth.Metadata["type"] != "plugin-provider" || auth.Attributes["path"] != path || auth.Attributes["source"] != path {
t.Fatalf("AuthDataToCoreAuth() metadata=%#v attributes=%#v, want path/source/type", auth.Metadata, auth.Attributes)
}
}
func TestPluginTokenStorageMergesRawMetadataAndProviderType(t *testing.T) {
storage := &pluginTokenStorage{
provider: "plugin-provider",
rawJSON: []byte(`{"old":"value","type":"old-provider"}`),
}
storage.SetMetadata(map[string]any{
"new": "value",
"old": "override",
})
raw := storage.RawJSON()
var decoded map[string]any
if errUnmarshal := json.Unmarshal(raw, &decoded); errUnmarshal != nil {
t.Fatalf("RawJSON() decode error = %v", errUnmarshal)
}
if decoded["old"] != "override" || decoded["new"] != "value" || decoded["type"] != "plugin-provider" {
t.Fatalf("RawJSON() decoded = %#v, want merged metadata and provider type", decoded)
}
path := filepath.Join(t.TempDir(), "auth.json")
if errSave := storage.SaveTokenToFile(path); errSave != nil {
t.Fatalf("SaveTokenToFile() error = %v", errSave)
}
saved, errReadFile := os.ReadFile(path)
if errReadFile != nil {
t.Fatalf("ReadFile(saved token) error = %v", errReadFile)
}
decoded = nil
if errUnmarshal := json.Unmarshal(saved, &decoded); errUnmarshal != nil {
t.Fatalf("saved token decode error = %v", errUnmarshal)
}
if decoded["old"] != "override" || decoded["new"] != "value" || decoded["type"] != "plugin-provider" {
t.Fatalf("saved token decoded = %#v, want merged metadata and provider type", decoded)
}
}
func TestPluginTokenStorageNormalizesCredentialMetadataKeys(t *testing.T) {
tests := []struct {
name string
rawJSON []byte
metadata map[string]any
want map[string]any
}{
{
name: "legacy raw keys",
rawJSON: []byte(`{"request-retry":2,"disable-cooling":true,"provider-specific-key":"preserved"}`),
want: map[string]any{
"request_retry": float64(2),
"disable_cooling": true,
"provider-specific-key": "preserved",
"type": "plugin-provider",
},
},
{
name: "canonical metadata wins",
rawJSON: []byte(`{"request-retry":2,"disable-cooling":true}`),
metadata: map[string]any{
"request_retry": 0,
"disable_cooling": false,
},
want: map[string]any{
"request_retry": float64(0),
"disable_cooling": false,
"type": "plugin-provider",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
storage := &pluginTokenStorage{
provider: "plugin-provider",
rawJSON: test.rawJSON,
}
storage.SetMetadata(test.metadata)
outputs := map[string][]byte{
"RawJSON": storage.RawJSON(),
}
path := filepath.Join(t.TempDir(), "auth.json")
if errSave := storage.SaveTokenToFile(path); errSave != nil {
t.Fatalf("SaveTokenToFile() error = %v", errSave)
}
saved, errReadFile := os.ReadFile(path)
if errReadFile != nil {
t.Fatalf("ReadFile(saved token) error = %v", errReadFile)
}
outputs["SaveTokenToFile"] = saved
for outputName, payload := range outputs {
var decoded map[string]any
if errUnmarshal := json.Unmarshal(payload, &decoded); errUnmarshal != nil {
t.Fatalf("%s decode error = %v", outputName, errUnmarshal)
}
if !reflect.DeepEqual(decoded, test.want) {
t.Errorf("%s decoded = %#v, want %#v", outputName, decoded, test.want)
}
if _, exists := decoded["request-retry"]; exists {
t.Errorf("%s retained request-retry: %#v", outputName, decoded)
}
if _, exists := decoded["disable-cooling"]; exists {
t.Errorf("%s retained disable-cooling: %#v", outputName, decoded)
}
}
})
}
}
func TestPluginTokenStorageSkipsUnchangedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "auth.json")
if errWriteFile := os.WriteFile(path, []byte(`{"disabled":false,"token":"secret","type":"plugin-provider"}`), 0o600); errWriteFile != nil {
t.Fatalf("WriteFile() error = %v", errWriteFile)
}
before, errStatBefore := os.Stat(path)
if errStatBefore != nil {
t.Fatalf("Stat(before) error = %v", errStatBefore)
}
storage := &pluginTokenStorage{
provider: "plugin-provider",
rawJSON: []byte(`{"token":"secret"}`),
}
storage.SetMetadata(map[string]any{"disabled": false})
if errSave := storage.SaveTokenToFile(path); errSave != nil {
t.Fatalf("SaveTokenToFile() error = %v", errSave)
}
after, errStatAfter := os.Stat(path)
if errStatAfter != nil {
t.Fatalf("Stat(after) error = %v", errStatAfter)
}
if !os.SameFile(before, after) {
t.Fatal("SaveTokenToFile() replaced unchanged auth file, want write skipped")
}
}
func TestPluginTokenStorageRejectsEmptyPayload(t *testing.T) {
storage := &pluginTokenStorage{}
if raw := storage.RawJSON(); raw != nil {
t.Fatalf("RawJSON() = %q, want nil for empty payload", raw)
}
if errSave := storage.SaveTokenToFile(filepath.Join(t.TempDir(), "auth.json")); errSave == nil {
t.Fatal("SaveTokenToFile() error = nil, want empty payload error")
}
}

View file

@ -0,0 +1,139 @@
package pluginhost
import (
"context"
"strconv"
"strings"
"sync"
"sync/atomic"
)
type callbackContextRegistry struct {
next atomic.Uint64
mu sync.RWMutex
contexts map[string]callbackContextEntry
}
type callbackContextEntry struct {
ctx context.Context
pluginID string
cleanup []func()
}
func newCallbackContextRegistry() *callbackContextRegistry {
return &callbackContextRegistry{contexts: make(map[string]callbackContextEntry)}
}
func (r *callbackContextRegistry) open(ctx context.Context, pluginID string) (string, func()) {
if r == nil {
return "", func() {}
}
if ctx == nil {
ctx = context.Background()
}
pluginID = strings.TrimSpace(pluginID)
ctx = withHostCallbackPluginID(ctx, pluginID)
id := strconv.FormatUint(r.next.Add(1), 10)
r.mu.Lock()
r.contexts[id] = callbackContextEntry{ctx: ctx, pluginID: pluginID}
r.mu.Unlock()
var once sync.Once
return id, func() {
once.Do(func() {
var cleanup []func()
r.mu.Lock()
entry := r.contexts[id]
delete(r.contexts, id)
r.mu.Unlock()
cleanup = entry.cleanup
for _, fn := range cleanup {
if fn != nil {
fn()
}
}
})
}
}
func (r *callbackContextRegistry) pluginID(id string) string {
if r == nil || id == "" {
return ""
}
r.mu.RLock()
entry := r.contexts[id]
r.mu.RUnlock()
return strings.TrimSpace(entry.pluginID)
}
func (r *callbackContextRegistry) addCleanup(id string, cleanup func()) bool {
if r == nil || id == "" || cleanup == nil {
return false
}
r.mu.Lock()
entry, ok := r.contexts[id]
if ok {
entry.cleanup = append(entry.cleanup, cleanup)
r.contexts[id] = entry
}
r.mu.Unlock()
if !ok {
cleanup()
return false
}
return true
}
func (r *callbackContextRegistry) resolve(id string, fallback context.Context) context.Context {
if fallback == nil {
fallback = context.Background()
}
if r == nil || id == "" {
return fallback
}
r.mu.RLock()
ctx := r.contexts[id].ctx
r.mu.RUnlock()
if ctx == nil {
return fallback
}
return ctx
}
func (h *Host) openCallbackContext(ctx context.Context) (string, func()) {
return h.openCallbackContextForPlugin(ctx, "")
}
func (h *Host) openCallbackContextForPlugin(ctx context.Context, pluginID string) (string, func()) {
if h == nil || h.callbackContexts == nil {
return "", func() {}
}
return h.callbackContexts.open(ctx, pluginID)
}
func (h *Host) addCallbackCleanup(id string, cleanup func()) bool {
if h == nil || h.callbackContexts == nil {
if id != "" && cleanup != nil {
cleanup()
}
return false
}
return h.callbackContexts.addCleanup(id, cleanup)
}
func (h *Host) resolveCallbackContext(id string, fallback context.Context) context.Context {
if h == nil || h.callbackContexts == nil {
if fallback == nil {
return context.Background()
}
return fallback
}
return h.callbackContexts.resolve(id, fallback)
}
func (h *Host) callbackContextPluginID(id string) string {
if h == nil || h.callbackContexts == nil {
return ""
}
return h.callbackContexts.pluginID(id)
}

View file

@ -0,0 +1,128 @@
package pluginhost
import (
"context"
"fmt"
"sync"
)
type guardedPluginClient struct {
mu sync.Mutex
cond *sync.Cond
inner pluginClient
calls int
closed bool
shutdownDone chan struct{}
}
func newGuardedPluginClient(inner pluginClient) *guardedPluginClient {
client := &guardedPluginClient{inner: inner, shutdownDone: make(chan struct{})}
client.cond = sync.NewCond(&client.mu)
return client
}
func (c *guardedPluginClient) Call(ctx context.Context, method string, request []byte) ([]byte, error) {
inner, errAcquire := c.acquire()
if errAcquire != nil {
return nil, errAcquire
}
if ctx == nil {
ctx = context.Background()
}
result := make(chan guardedPluginCallResult, 1)
go func() {
defer c.release()
defer func() {
if recovered := recover(); recovered != nil {
result <- guardedPluginCallResult{recovered: recovered}
}
}()
response, errCall := inner.Call(ctx, method, request)
result <- guardedPluginCallResult{response: response, err: errCall}
}()
select {
case callResult := <-result:
if callResult.recovered != nil {
panic(callResult.recovered)
}
return callResult.response, callResult.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
type guardedPluginCallResult struct {
response []byte
err error
recovered any
}
func (c *guardedPluginClient) acquire() (pluginClient, error) {
if c == nil {
return nil, fmt.Errorf("plugin client is closed")
}
c.mu.Lock()
defer c.mu.Unlock()
if c.closed || c.inner == nil {
return nil, fmt.Errorf("plugin client is closed")
}
c.calls++
return c.inner, nil
}
func (c *guardedPluginClient) release() {
c.mu.Lock()
c.calls--
if c.calls == 0 {
c.cond.Broadcast()
}
c.mu.Unlock()
}
func (c *guardedPluginClient) Shutdown() {
c.ShutdownContext(context.Background())
}
// ShutdownContext detaches the client immediately and waits for active calls only
// until ctx is canceled. Detached cleanup continues asynchronously when needed.
func (c *guardedPluginClient) ShutdownContext(ctx context.Context) {
if c == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
c.mu.Lock()
if c.closed {
done := c.shutdownDone
c.mu.Unlock()
select {
case <-done:
case <-ctx.Done():
}
return
}
c.closed = true
inner := c.inner
c.inner = nil
done := c.shutdownDone
c.mu.Unlock()
go func() {
c.mu.Lock()
for c.calls > 0 {
c.cond.Wait()
}
c.mu.Unlock()
if inner != nil {
inner.Shutdown()
}
close(done)
}()
select {
case <-done:
case <-ctx.Done():
}
}

View file

@ -0,0 +1,70 @@
package pluginhost
import (
"context"
"sync/atomic"
"testing"
"time"
)
type blockingGuardPluginClient struct {
started chan struct{}
release chan struct{}
shutdown atomic.Int32
}
func (c *blockingGuardPluginClient) Call(context.Context, string, []byte) ([]byte, error) {
close(c.started)
<-c.release
return nil, nil
}
func (c *blockingGuardPluginClient) Shutdown() {
c.shutdown.Add(1)
}
func TestGuardedPluginClientShutdownContextDetachesBlockedCall(t *testing.T) {
inner := &blockingGuardPluginClient{started: make(chan struct{}), release: make(chan struct{})}
guarded := newGuardedPluginClient(inner)
callDone := make(chan struct{})
go func() {
_, _ = guarded.Call(context.Background(), "blocked", nil)
close(callDone)
}()
select {
case <-inner.started:
case <-time.After(time.Second):
t.Fatal("guarded call did not start")
}
shutdownCtx, cancelShutdown := context.WithCancel(context.Background())
cancelShutdown()
shutdownDone := make(chan struct{})
go func() {
guarded.ShutdownContext(shutdownCtx)
close(shutdownDone)
}()
select {
case <-shutdownDone:
case <-time.After(time.Second):
t.Fatal("context-canceled guarded shutdown waited for the active call")
}
if got := inner.shutdown.Load(); got != 0 {
t.Fatalf("shutdown calls before active call exits = %d, want 0", got)
}
close(inner.release)
select {
case <-callDone:
case <-time.After(time.Second):
t.Fatal("guarded call did not exit")
}
deadline := time.Now().Add(time.Second)
for inner.shutdown.Load() == 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if got := inner.shutdown.Load(); got != 1 {
t.Fatalf("shutdown calls after active call exits = %d, want 1", got)
}
}

View file

@ -0,0 +1,420 @@
package pluginhost
import (
"context"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
type commandLineFlagRecord struct {
pluginID string
flag pluginapi.CommandLineFlag
value string
set bool
}
// RegisterCommandLineFlags exposes plugin-declared flags on the provided FlagSet.
func (h *Host) RegisterCommandLineFlags(ctx context.Context, flagSet *flag.FlagSet) {
if h == nil || flagSet == nil {
return
}
for _, record := range h.activeRecords() {
plugin := record.plugin.Capabilities.CommandLinePlugin
if plugin == nil || h.isPluginFused(record.id) {
continue
}
resp, errRegister := h.callCommandLineRegistrar(ctx, record, plugin)
if errRegister != nil {
log.Warnf("pluginhost: command-line registrar %s failed: %v", record.id, errRegister)
continue
}
for _, item := range resp.Flags {
h.registerCommandLineFlag(flagSet, record.id, item)
}
}
}
func (h *Host) callCommandLineRegistrar(ctx context.Context, record capabilityRecord, plugin pluginapi.CommandLinePlugin) (resp pluginapi.CommandLineRegistrationResponse, err error) {
if h == nil || plugin == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.CommandLineRegistrationResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "CommandLinePlugin.RegisterCommandLine", recovered)
resp = pluginapi.CommandLineRegistrationResponse{}
err = fmt.Errorf("command-line registrar panic: %v", recovered)
}
}()
return plugin.RegisterCommandLine(ctx, pluginapi.CommandLineRegistrationRequest{Plugin: record.meta})
}
func (h *Host) registerCommandLineFlag(flagSet *flag.FlagSet, pluginID string, item pluginapi.CommandLineFlag) {
name := strings.TrimSpace(item.Name)
if !validCommandLineFlagName(name) {
log.Warnf("pluginhost: plugin %s declared invalid command-line flag %q", pluginID, item.Name)
return
}
kind := normalizeCommandLineFlagType(item.Type)
if kind == "" {
log.Warnf("pluginhost: plugin %s declared unsupported command-line flag type %q for %s", pluginID, item.Type, name)
return
}
value, okDefault := normalizeCommandLineFlagValue(kind, item.DefaultValue)
if !okDefault {
log.Warnf("pluginhost: plugin %s declared invalid default value %q for %s", pluginID, item.DefaultValue, name)
return
}
if flagSet.Lookup(name) != nil {
log.Warnf("pluginhost: plugin %s command-line flag %s conflicts with an existing flag and was skipped", pluginID, name)
return
}
h.mu.Lock()
if _, exists := h.commandLineFlags[name]; exists {
h.mu.Unlock()
log.Warnf("pluginhost: plugin %s command-line flag %s conflicts with a higher-priority plugin and was skipped", pluginID, name)
return
}
h.commandLineFlags[name] = commandLineFlagRecord{
pluginID: pluginID,
flag: pluginapi.CommandLineFlag{
Name: name,
Usage: item.Usage,
Type: kind,
DefaultValue: value,
},
value: value,
}
h.mu.Unlock()
flagSet.Var(&commandLineFlagValue{
host: h,
name: name,
kind: kind,
}, name, item.Usage)
}
func validCommandLineFlagName(name string) bool {
return name != "" &&
!strings.HasPrefix(name, "-") &&
name != "help" &&
name != "h" &&
!strings.ContainsAny(name, " \t\r\n=")
}
func normalizeCommandLineFlagType(kind string) string {
switch strings.ToLower(strings.TrimSpace(kind)) {
case "", "bool":
return "bool"
case "string":
return "string"
case "int":
return "int"
case "int64":
return "int64"
case "float64":
return "float64"
case "duration":
return "duration"
default:
return ""
}
}
func normalizeCommandLineFlagValue(kind, value string) (string, bool) {
switch kind {
case "bool":
if strings.TrimSpace(value) == "" {
return "false", true
}
parsed, errParse := strconv.ParseBool(value)
if errParse != nil {
return "", false
}
return strconv.FormatBool(parsed), true
case "string":
return value, true
case "int":
if strings.TrimSpace(value) == "" {
return "0", true
}
parsed, errParse := strconv.Atoi(value)
if errParse != nil {
return "", false
}
return strconv.Itoa(parsed), true
case "int64":
if strings.TrimSpace(value) == "" {
return "0", true
}
parsed, errParse := strconv.ParseInt(value, 10, 64)
if errParse != nil {
return "", false
}
return strconv.FormatInt(parsed, 10), true
case "float64":
if strings.TrimSpace(value) == "" {
return "0", true
}
parsed, errParse := strconv.ParseFloat(value, 64)
if errParse != nil {
return "", false
}
return strconv.FormatFloat(parsed, 'g', -1, 64), true
case "duration":
if strings.TrimSpace(value) == "" {
return "0s", true
}
parsed, errParse := time.ParseDuration(value)
if errParse != nil {
return "", false
}
return parsed.String(), true
default:
return "", false
}
}
type commandLineFlagValue struct {
host *Host
name string
kind string
}
func (v *commandLineFlagValue) String() string {
if v == nil || v.host == nil {
return ""
}
v.host.mu.Lock()
defer v.host.mu.Unlock()
return v.host.commandLineFlags[v.name].value
}
func (v *commandLineFlagValue) Set(raw string) error {
if v == nil || v.host == nil {
return nil
}
normalized, okValue := normalizeCommandLineFlagValue(v.kind, raw)
if !okValue {
return fmt.Errorf("invalid %s value %q", v.kind, raw)
}
v.host.mu.Lock()
record, okRecord := v.host.commandLineFlags[v.name]
if okRecord {
record.value = normalized
record.set = true
v.host.commandLineFlags[v.name] = record
v.host.commandLineHits[v.name] = struct{}{}
}
v.host.mu.Unlock()
return nil
}
func (v *commandLineFlagValue) IsBoolFlag() bool {
return v != nil && v.kind == "bool"
}
// HasTriggeredCommandLineFlags reports whether any plugin-owned flag was provided.
func (h *Host) HasTriggeredCommandLineFlags() bool {
if h == nil {
return false
}
h.mu.Lock()
defer h.mu.Unlock()
return len(h.commandLineHits) > 0
}
// ExecuteCommandLine runs all enabled plugins whose command-line flags were provided.
func (h *Host) ExecuteCommandLine(ctx context.Context, program string, args []string, configPath string, flagSet *flag.FlagSet) (int, bool) {
if h == nil {
return 0, false
}
triggeredByPlugin, allFlags := h.commandLineExecutionState(flagSet)
if len(triggeredByPlugin) == 0 {
return 0, false
}
exitCode := 0
handled := false
for _, record := range h.activeRecords() {
plugin := record.plugin.Capabilities.CommandLinePlugin
if plugin == nil || h.isPluginFused(record.id) {
continue
}
triggered := triggeredByPlugin[record.id]
if len(triggered) == 0 {
continue
}
handled = true
resp, errExecute := h.callCommandLineExecutor(ctx, record, plugin, pluginapi.CommandLineExecutionRequest{
Plugin: record.meta,
Program: program,
Args: append([]string(nil), args...),
ConfigPath: configPath,
Host: h.hostConfigSummary(),
Flags: cloneCommandLineFlagValues(allFlags),
TriggeredFlags: cloneCommandLineFlagValues(triggered),
})
if errExecute != nil {
log.Warnf("pluginhost: command-line plugin %s failed: %v", record.id, errExecute)
if exitCode == 0 {
exitCode = 1
}
continue
}
if resp.ExitCode == 0 && len(resp.Auths) > 0 {
savedPaths, errPersist := h.persistCommandLineAuths(ctx, resp.Auths)
if errPersist != nil {
writeCommandLineOutput(os.Stdout, resp.Stdout)
writeCommandLineOutput(os.Stderr, resp.Stderr)
writeCommandLineOutput(os.Stderr, []byte(errPersist.Error()+"\n"))
if exitCode == 0 {
exitCode = 1
}
continue
}
resp.Stdout = appendCommandLineSavedPaths(resp.Stdout, savedPaths)
}
writeCommandLineOutput(os.Stdout, resp.Stdout)
writeCommandLineOutput(os.Stderr, resp.Stderr)
if resp.ExitCode != 0 && exitCode == 0 {
exitCode = resp.ExitCode
}
}
return exitCode, handled
}
func (h *Host) commandLineExecutionState(flagSet *flag.FlagSet) (map[string]map[string]pluginapi.CommandLineFlagValue, map[string]pluginapi.CommandLineFlagValue) {
triggeredByPlugin := make(map[string]map[string]pluginapi.CommandLineFlagValue)
allFlags := make(map[string]pluginapi.CommandLineFlagValue)
setFlags := make(map[string]struct{})
if flagSet != nil {
flagSet.Visit(func(f *flag.Flag) {
setFlags[f.Name] = struct{}{}
})
flagSet.VisitAll(func(f *flag.Flag) {
allFlags[f.Name] = pluginapi.CommandLineFlagValue{
Name: f.Name,
Type: "",
Value: f.Value.String(),
Set: false,
}
})
}
h.mu.Lock()
defer h.mu.Unlock()
for name, record := range h.commandLineFlags {
value := pluginapi.CommandLineFlagValue{
Name: name,
Type: record.flag.Type,
Value: record.value,
Set: record.set,
}
if _, set := setFlags[name]; set {
value.Set = true
}
allFlags[name] = value
if _, hit := h.commandLineHits[name]; !hit {
continue
}
if triggeredByPlugin[record.pluginID] == nil {
triggeredByPlugin[record.pluginID] = make(map[string]pluginapi.CommandLineFlagValue)
}
triggeredByPlugin[record.pluginID][name] = value
}
return triggeredByPlugin, allFlags
}
func cloneCommandLineFlagValues(in map[string]pluginapi.CommandLineFlagValue) map[string]pluginapi.CommandLineFlagValue {
if len(in) == 0 {
return nil
}
out := make(map[string]pluginapi.CommandLineFlagValue, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func (h *Host) callCommandLineExecutor(ctx context.Context, record capabilityRecord, plugin pluginapi.CommandLinePlugin, req pluginapi.CommandLineExecutionRequest) (resp pluginapi.CommandLineExecutionResponse, err error) {
if h == nil || plugin == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.CommandLineExecutionResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "CommandLinePlugin.ExecuteCommandLine", recovered)
resp = pluginapi.CommandLineExecutionResponse{}
err = fmt.Errorf("command-line execution panic: %v", recovered)
}
}()
return plugin.ExecuteCommandLine(ctx, req)
}
func (h *Host) persistCommandLineAuths(ctx context.Context, auths []pluginapi.AuthData) ([]string, error) {
if len(auths) == 0 {
return nil, nil
}
store := sdkAuth.GetTokenStore()
if store == nil {
return nil, fmt.Errorf("pluginhost: token store unavailable")
}
summary := h.hostConfigSummary()
if summary.AuthDir != "" {
if setter, okSetter := store.(interface{ SetBaseDir(string) }); okSetter {
setter.SetBaseDir(summary.AuthDir)
}
}
savedPaths := make([]string, 0, len(auths))
for index, authData := range auths {
record := h.AuthDataToCoreAuth(authData, "", "")
if record == nil {
return savedPaths, fmt.Errorf("pluginhost: command-line auth %d is invalid", index+1)
}
savedPath, errSave := store.Save(ctx, record)
if errSave != nil {
return savedPaths, fmt.Errorf("pluginhost: save command-line auth %s: %w", record.ID, errSave)
}
if strings.TrimSpace(savedPath) != "" {
savedPaths = append(savedPaths, savedPath)
}
}
return savedPaths, nil
}
func appendCommandLineSavedPaths(stdout []byte, savedPaths []string) []byte {
if len(savedPaths) == 0 {
return stdout
}
out := append([]byte(nil), stdout...)
if len(out) > 0 && out[len(out)-1] != '\n' {
out = append(out, '\n')
}
for _, savedPath := range savedPaths {
if strings.TrimSpace(savedPath) == "" {
continue
}
out = append(out, []byte(fmt.Sprintf("Authentication saved to %s\n", savedPath))...)
}
return out
}
func writeCommandLineOutput(w io.Writer, data []byte) {
if w == nil || len(data) == 0 {
return
}
if _, errWrite := w.Write(data); errWrite != nil {
log.Warnf("pluginhost: failed to write command-line plugin output: %v", errWrite)
}
}

View file

@ -0,0 +1,212 @@
package pluginhost
import (
"bytes"
"context"
"flag"
"path/filepath"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestRegisterCommandLineFlagsSkipsNativeAndUsesPriority(t *testing.T) {
flagSet := flag.NewFlagSet("test", flag.ContinueOnError)
flagSet.SetOutput(&bytes.Buffer{})
flagSet.Bool("native", false, "native flag")
high := &commandLinePluginDouble{
flags: []pluginapi.CommandLineFlag{
{Name: "native", Type: "bool", Usage: "conflicting native flag"},
{Name: "help", Type: "bool", Usage: "reserved help flag"},
{Name: "h", Type: "bool", Usage: "reserved short help flag"},
{Name: "shared", Type: "string", Usage: "shared flag"},
},
}
low := &commandLinePluginDouble{
flags: []pluginapi.CommandLineFlag{
{Name: "shared", Type: "string", Usage: "lower priority shared flag"},
{Name: "low-only", Type: "int", Usage: "low priority flag"},
},
}
host := newHostWithRecords(
capabilityRecord{id: "low", priority: 1, plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{CommandLinePlugin: low}}},
capabilityRecord{id: "high", priority: 10, plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{CommandLinePlugin: high}}},
)
host.RegisterCommandLineFlags(context.Background(), flagSet)
if flagSet.Lookup("native") == nil {
t.Fatal("native flag missing")
}
if flagSet.Lookup("shared") == nil {
t.Fatal("shared plugin flag missing")
}
if flagSet.Lookup("low-only") == nil {
t.Fatal("low-only plugin flag missing")
}
if got := host.commandLineFlags["shared"].pluginID; got != "high" {
t.Fatalf("shared owner = %q, want high", got)
}
if _, exists := host.commandLineFlags["native"]; exists {
t.Fatal("native flag was claimed by plugin")
}
if _, exists := host.commandLineFlags["help"]; exists {
t.Fatal("reserved help flag was claimed by plugin")
}
if _, exists := host.commandLineFlags["h"]; exists {
t.Fatal("reserved h flag was claimed by plugin")
}
}
func TestExecuteCommandLinePassesAllArgsAndTriggeredFlags(t *testing.T) {
flagSet := flag.NewFlagSet("test", flag.ContinueOnError)
flagSet.SetOutput(&bytes.Buffer{})
plugin := &commandLinePluginDouble{
flags: []pluginapi.CommandLineFlag{{
Name: "plugin-command",
Type: "bool",
}},
}
host := newHostWithRecords(capabilityRecord{
id: "alpha",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{CommandLinePlugin: plugin}},
})
host.runtimeConfig = &config.Config{AuthDir: "/tmp/plugin-auth"}
host.RegisterCommandLineFlags(context.Background(), flagSet)
if errParse := flagSet.Parse([]string{"-plugin-command", "tail"}); errParse != nil {
t.Fatalf("Parse() error = %v", errParse)
}
if !host.HasTriggeredCommandLineFlags() {
t.Fatal("HasTriggeredCommandLineFlags() = false, want true")
}
exitCode, handled := host.ExecuteCommandLine(context.Background(), "cliproxy", []string{"-plugin-command", "tail"}, "/tmp/config.yaml", flagSet)
if !handled {
t.Fatal("ExecuteCommandLine() handled = false, want true")
}
if exitCode != 0 {
t.Fatalf("ExecuteCommandLine() exitCode = %d, want 0", exitCode)
}
if len(plugin.execRequests) != 1 {
t.Fatalf("execute calls = %d, want 1", len(plugin.execRequests))
}
req := plugin.execRequests[0]
if req.Program != "cliproxy" || req.ConfigPath != "/tmp/config.yaml" {
t.Fatalf("execution request = %#v, want program and config path", req)
}
if req.Host.AuthDir != "/tmp/plugin-auth" {
t.Fatalf("execution request host = %#v, want auth dir", req.Host)
}
if len(req.Args) != 2 || req.Args[0] != "-plugin-command" || req.Args[1] != "tail" {
t.Fatalf("Args = %#v, want full args", req.Args)
}
if got := req.TriggeredFlags["plugin-command"]; !got.Set || got.Value != "true" {
t.Fatalf("TriggeredFlags[plugin-command] = %#v, want set true", got)
}
}
func TestExecuteCommandLinePersistsReturnedAuths(t *testing.T) {
authDir := t.TempDir()
store := &commandLineAuthStore{}
origStore := sdkAuth.GetTokenStore()
sdkAuth.RegisterTokenStore(store)
defer sdkAuth.RegisterTokenStore(origStore)
flagSet := flag.NewFlagSet("test", flag.ContinueOnError)
flagSet.SetOutput(&bytes.Buffer{})
plugin := &commandLinePluginDouble{
flags: []pluginapi.CommandLineFlag{{
Name: "plugin-login",
Type: "bool",
}},
response: pluginapi.CommandLineExecutionResponse{
Stdout: []byte("login ok\n"),
Auths: []pluginapi.AuthData{{
Provider: "Sample-Provider",
ID: "sample-provider.json",
FileName: "sample-provider.json",
Label: "Luis",
StorageJSON: []byte(`{"token":"secret"}`),
}},
},
}
host := newHostWithRecords(capabilityRecord{
id: "sample-provider",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{CommandLinePlugin: plugin}},
})
host.runtimeConfig = &config.Config{AuthDir: authDir}
host.RegisterCommandLineFlags(context.Background(), flagSet)
if errParse := flagSet.Parse([]string{"-plugin-login"}); errParse != nil {
t.Fatalf("Parse() error = %v", errParse)
}
exitCode, handled := host.ExecuteCommandLine(context.Background(), "cliproxy", []string{"-plugin-login"}, "/tmp/config.yaml", flagSet)
if !handled {
t.Fatal("ExecuteCommandLine() handled = false, want true")
}
if exitCode != 0 {
t.Fatalf("ExecuteCommandLine() exitCode = %d, want 0", exitCode)
}
if store.baseDir != authDir {
t.Fatalf("store baseDir = %q, want %q", store.baseDir, authDir)
}
if len(store.saved) != 1 {
t.Fatalf("saved auths = %d, want 1", len(store.saved))
}
saved := store.saved[0]
if saved.Provider != "sample-provider" || saved.ID != "sample-provider.json" || saved.FileName != "sample-provider.json" {
t.Fatalf("saved auth = %#v, want normalized sample provider auth", saved)
}
if saved.Storage == nil {
t.Fatal("saved auth storage = nil, want plugin token storage")
}
if store.paths[0] != filepath.Join(authDir, "sample-provider.json") {
t.Fatalf("saved path = %q, want auth dir path", store.paths[0])
}
}
type commandLinePluginDouble struct {
flags []pluginapi.CommandLineFlag
execRequests []pluginapi.CommandLineExecutionRequest
response pluginapi.CommandLineExecutionResponse
}
func (p *commandLinePluginDouble) RegisterCommandLine(context.Context, pluginapi.CommandLineRegistrationRequest) (pluginapi.CommandLineRegistrationResponse, error) {
return pluginapi.CommandLineRegistrationResponse{Flags: p.flags}, nil
}
func (p *commandLinePluginDouble) ExecuteCommandLine(ctx context.Context, req pluginapi.CommandLineExecutionRequest) (pluginapi.CommandLineExecutionResponse, error) {
p.execRequests = append(p.execRequests, req)
return p.response, nil
}
type commandLineAuthStore struct {
baseDir string
saved []*coreauth.Auth
paths []string
}
func (s *commandLineAuthStore) List(context.Context) ([]*coreauth.Auth, error) {
return nil, nil
}
func (s *commandLineAuthStore) Save(_ context.Context, auth *coreauth.Auth) (string, error) {
s.saved = append(s.saved, auth.Clone())
path := filepath.Join(s.baseDir, auth.FileName)
s.paths = append(s.paths, path)
return path, nil
}
func (s *commandLineAuthStore) Delete(context.Context, string) error {
return nil
}
func (s *commandLineAuthStore) SetBaseDir(dir string) {
s.baseDir = dir
}

View file

@ -0,0 +1,229 @@
package pluginhost
import (
"bytes"
"sort"
"strconv"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"gopkg.in/yaml.v3"
)
var defaultRuntimeConfigYAML = []byte("enabled: false\npriority: 0\n")
type runtimeConfig struct {
Enabled bool
Dir string
Items map[string]runtimeItemConfig
}
type runtimeItemConfig struct {
ID string
Enabled bool
Priority int
Version string
ConfigYAML []byte
}
func runtimeConfigFromConfig(cfg *config.Config) (runtimeConfig, error) {
out := runtimeConfig{
Dir: "plugins",
Items: make(map[string]runtimeItemConfig),
}
if cfg == nil {
return out, nil
}
out.Enabled = cfg.Plugins.Enabled
if !out.Enabled {
return out, nil
}
pluginsDir, errResolvePluginsDir := config.ResolvePluginsDir(cfg.Plugins.Dir)
if errResolvePluginsDir != nil {
return runtimeConfig{}, errResolvePluginsDir
}
out.Dir = pluginsDir
ids := make([]string, 0, len(cfg.Plugins.Configs))
for id := range cfg.Plugins.Configs {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
item := cfg.Plugins.Configs[id]
enabled := false
if item.Enabled != nil {
enabled = *item.Enabled
}
out.Items[id] = runtimeItemConfig{
ID: id,
Enabled: enabled,
Priority: item.Priority,
Version: pluginConfigDesiredVersion(item),
ConfigYAML: runtimeConfigYAML(item, enabled),
}
}
return out, nil
}
func defaultRuntimeItemConfig(id string) runtimeItemConfig {
return runtimeItemConfig{
ID: id,
Enabled: false,
Priority: 0,
ConfigYAML: append([]byte(nil), defaultRuntimeConfigYAML...),
}
}
func runtimeConfigYAML(item config.PluginInstanceConfig, enabled bool) []byte {
rawNode := normalizedConfigNode(item, enabled)
rawYAML := bytes.TrimSpace(mustMarshalYAML(rawNode))
if len(rawYAML) == 0 {
return append([]byte(nil), defaultRuntimeConfigYAML...)
}
return append(append([]byte(nil), rawYAML...), '\n')
}
func desiredPluginVersions(items map[string]runtimeItemConfig) map[string]string {
if len(items) == 0 {
return nil
}
out := make(map[string]string, len(items))
for id, item := range items {
id = strings.TrimSpace(id)
version := strings.TrimSpace(item.Version)
if id == "" || version == "" {
continue
}
out[id] = version
}
if len(out) == 0 {
return nil
}
return out
}
func pluginConfigDesiredVersion(item config.PluginInstanceConfig) string {
storeNode := yamlMappingValue(&item.Raw, "store")
if storeNode == nil {
return ""
}
if version := normalizePluginDesiredVersion(yamlScalarString(yamlMappingValue(storeNode, "version"))); version != "" {
return version
}
return normalizePluginDesiredVersion(yamlScalarString(yamlMappingValue(storeNode, "release-tag")))
}
func normalizePluginDesiredVersion(version string) string {
version = strings.TrimSpace(version)
if len(version) > 1 && (version[0] == 'v' || version[0] == 'V') {
version = version[1:]
}
if !validPluginVersion(version) {
return ""
}
return version
}
func yamlScalarString(node *yaml.Node) string {
if node == nil || node.Kind == 0 {
return ""
}
if node.Kind == yaml.ScalarNode {
return strings.TrimSpace(node.Value)
}
var value string
if errDecode := node.Decode(&value); errDecode != nil {
return ""
}
return strings.TrimSpace(value)
}
func yamlMappingValue(node *yaml.Node, key string) *yaml.Node {
if node == nil || node.Kind != yaml.MappingNode {
return nil
}
for index := 0; index+1 < len(node.Content); index += 2 {
if node.Content[index] != nil && node.Content[index].Value == key {
return node.Content[index+1]
}
}
return nil
}
func normalizedConfigNode(item config.PluginInstanceConfig, enabled bool) *yaml.Node {
if item.Raw.Kind == 0 {
return defaultRuntimeConfigNode(enabled, item.Priority)
}
node := deepCopyYAMLNode(&item.Raw)
if node.Kind != yaml.MappingNode {
return node
}
ensureMappingScalar(node, "enabled", boolYAMLValue(enabled), "!!bool")
ensureMappingScalar(node, "priority", intYAMLValue(item.Priority), "!!int")
return node
}
func defaultRuntimeConfigNode(enabled bool, priority int) *yaml.Node {
return &yaml.Node{
Kind: yaml.MappingNode,
Tag: "!!map",
Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"},
{Kind: yaml.ScalarNode, Tag: "!!bool", Value: boolYAMLValue(enabled)},
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "priority"},
{Kind: yaml.ScalarNode, Tag: "!!int", Value: intYAMLValue(priority)},
},
}
}
func ensureMappingScalar(node *yaml.Node, key, value, tag string) {
if node == nil || node.Kind != yaml.MappingNode {
return
}
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i] != nil && node.Content[i].Value == key {
return
}
}
node.Content = append(node.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
&yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value},
)
}
func boolYAMLValue(v bool) string {
if v {
return "true"
}
return "false"
}
func intYAMLValue(v int) string {
return strconv.Itoa(v)
}
func deepCopyYAMLNode(node *yaml.Node) *yaml.Node {
if node == nil {
return nil
}
copyNode := *node
if len(node.Content) > 0 {
copyNode.Content = make([]*yaml.Node, 0, len(node.Content))
for _, child := range node.Content {
copyNode.Content = append(copyNode.Content, deepCopyYAMLNode(child))
}
}
return &copyNode
}
func mustMarshalYAML(v any) []byte {
raw, errMarshal := yaml.Marshal(v)
if errMarshal != nil {
return append([]byte(nil), defaultRuntimeConfigYAML...)
}
return raw
}

View file

@ -0,0 +1,105 @@
package pluginhost
import (
"strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"gopkg.in/yaml.v3"
)
func TestRuntimeConfigYAMLAddsHostDefaultsToRawPluginConfig(t *testing.T) {
var node yaml.Node
if errDecode := yaml.Unmarshal([]byte("config1: true\nconfig2: value\n"), &node); errDecode != nil {
t.Fatalf("yaml.Unmarshal() error = %v", errDecode)
}
if len(node.Content) != 1 {
t.Fatalf("yaml node content length = %d, want 1", len(node.Content))
}
item := config.PluginInstanceConfig{
Priority: 3,
Raw: *node.Content[0],
}
got := string(runtimeConfigYAML(item, true))
for _, want := range []string{
"config1: true",
"config2: value",
"enabled: true",
"priority: 3",
} {
if !strings.Contains(got, want) {
t.Fatalf("runtimeConfigYAML() missing %q in:\n%s", want, got)
}
}
}
func TestRuntimeConfigYAMLDefaultsEnabledFalse(t *testing.T) {
item := config.PluginInstanceConfig{
Priority: 3,
}
got := string(runtimeConfigYAML(item, false))
for _, want := range []string{
"enabled: false",
"priority: 3",
} {
if !strings.Contains(got, want) {
t.Fatalf("runtimeConfigYAML() missing %q in:\n%s", want, got)
}
}
}
func TestRuntimeConfigFromConfigExtractsStoreVersion(t *testing.T) {
var node yaml.Node
if errDecode := yaml.Unmarshal([]byte("store:\n version: 1.0.3\n release-tag: v1.0.3\n"), &node); errDecode != nil {
t.Fatalf("yaml.Unmarshal() error = %v", errDecode)
}
enabled := true
cfg := &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Configs: map[string]config.PluginInstanceConfig{
"alpha": {
Enabled: &enabled,
Raw: *node.Content[0],
},
},
},
}
got, errRuntimeConfig := runtimeConfigFromConfig(cfg)
if errRuntimeConfig != nil {
t.Fatalf("runtimeConfigFromConfig() error = %v", errRuntimeConfig)
}
if got.Items["alpha"].Version != "1.0.3" {
t.Fatalf("runtimeConfigFromConfig() version = %q, want 1.0.3", got.Items["alpha"].Version)
}
}
func TestRuntimeConfigFromConfigDerivesStoreVersionFromReleaseTag(t *testing.T) {
var node yaml.Node
if errDecode := yaml.Unmarshal([]byte("store:\n release-tag: v1.0.3\n"), &node); errDecode != nil {
t.Fatalf("yaml.Unmarshal() error = %v", errDecode)
}
enabled := true
cfg := &config.Config{
Plugins: config.PluginsConfig{
Enabled: true,
Configs: map[string]config.PluginInstanceConfig{
"alpha": {
Enabled: &enabled,
Raw: *node.Content[0],
},
},
},
}
got, errRuntimeConfig := runtimeConfigFromConfig(cfg)
if errRuntimeConfig != nil {
t.Fatalf("runtimeConfigFromConfig() error = %v", errRuntimeConfig)
}
if got.Items["alpha"].Version != "1.0.3" {
t.Fatalf("runtimeConfigFromConfig() version = %q, want 1.0.3", got.Items["alpha"].Version)
}
}

View file

@ -0,0 +1,139 @@
package pluginhost
import (
"context"
"fmt"
"strings"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
// executorPluginReady reports whether the named plugin can actually execute a
// request right now: it must declare an executor capability AND resolve a
// non-empty provider identifier (the same requirement enforced by
// executorAdapterForPlugin at execution time), allow static execution without
// selected auth, and declare formats compatible with the current request.
// Routing pre-checks use this so that targets which would fail at execution are
// treated as unhandled and fall through to lower-priority routers instead of
// returning handled then 500ing.
func (h *Host) executorPluginReady(pluginID string, routeReq pluginapi.ModelRouteRequest) bool {
if h == nil {
return false
}
pluginID = strings.TrimSpace(pluginID)
if pluginID == "" {
return false
}
for _, record := range h.activeRecords() {
if record.id != pluginID || h.isPluginFused(record.id) {
continue
}
executor := record.plugin.Capabilities.Executor
if executor == nil {
return false
}
if !executorScopeAllowsStaticModels(record.plugin.Capabilities) {
return false
}
provider, okProvider := h.executorProvider(record, executor)
if !okProvider {
return false
}
adapter := newExecutorAdapterRegistration(h, record, provider, executor).adapter
return adapter.supportsExecutorFormats(
coreexecutor.Request{Model: routeReq.RequestedModel, Payload: routeReq.Body},
coreexecutor.Options{
Stream: routeReq.Stream,
OriginalRequest: routeReq.Body,
SourceFormat: sdktranslator.FromString(routeReq.SourceFormat),
ResponseFormat: sdktranslator.FromString(routeReq.SourceFormat),
Headers: cloneHeader(routeReq.Headers),
Query: cloneValues(routeReq.Query),
Metadata: cloneInterceptorMetadata(routeReq.Metadata),
},
)
}
return false
}
func (a *executorAdapter) supportsExecutorFormats(req coreexecutor.Request, opts coreexecutor.Options) bool {
if a == nil {
return false
}
inputRequested := executorInputFormat(req, opts)
requestedFormat := executorRequestedFormat(req, opts)
inputFormat, errInput := a.selectExecutorInputFormat(inputRequested)
if errInput != nil {
return false
}
_, errOutput := a.selectExecutorOutputFormat(requestedFormat, inputFormat)
return errOutput == nil
}
// PluginExecutorRequestToFormat reports the executor input format selected for a direct plugin executor route.
func (h *Host) PluginExecutorRequestToFormat(pluginID string, req coreexecutor.Request, opts coreexecutor.Options) sdktranslator.Format {
adapter, errAdapter := h.executorAdapterForPlugin(pluginID)
if errAdapter != nil {
return ""
}
return adapter.RequestToFormat(req, opts)
}
// ExecutePluginExecutor executes a request with the named plugin executor without changing the requested model.
func (h *Host) ExecutePluginExecutor(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
adapter, errAdapter := h.executorAdapterForPlugin(pluginID)
if errAdapter != nil {
return coreexecutor.Response{}, errAdapter
}
return adapter.Execute(ctx, (*coreauth.Auth)(nil), req, opts)
}
// ExecutePluginExecutorStream executes a streaming request with the named plugin executor without changing the requested model.
func (h *Host) ExecutePluginExecutorStream(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) {
adapter, errAdapter := h.executorAdapterForPlugin(pluginID)
if errAdapter != nil {
return nil, errAdapter
}
return adapter.ExecuteStream(ctx, (*coreauth.Auth)(nil), req, opts)
}
// CountPluginExecutor executes a count-tokens request with the named plugin executor without changing the requested model.
func (h *Host) CountPluginExecutor(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
adapter, errAdapter := h.executorAdapterForPlugin(pluginID)
if errAdapter != nil {
return coreexecutor.Response{}, errAdapter
}
return adapter.CountTokens(ctx, (*coreauth.Auth)(nil), req, opts)
}
func (h *Host) executorAdapterForPlugin(pluginID string) (*executorAdapter, error) {
if h == nil {
return nil, fmt.Errorf("plugin host is unavailable")
}
pluginID = strings.TrimSpace(pluginID)
if pluginID == "" {
return nil, fmt.Errorf("target executor plugin id is required")
}
for _, record := range h.activeRecords() {
if record.id != pluginID {
continue
}
if h.isPluginFused(record.id) {
return nil, fmt.Errorf("plugin executor %s is unavailable", pluginID)
}
executor := record.plugin.Capabilities.Executor
if executor == nil {
return nil, fmt.Errorf("plugin %s does not declare an executor", pluginID)
}
provider, okProvider := h.executorProvider(record, executor)
if !okProvider {
return nil, fmt.Errorf("plugin executor %s has no provider identifier", pluginID)
}
registration := newExecutorAdapterRegistration(h, record, provider, executor)
return registration.adapter, nil
}
return nil, fmt.Errorf("plugin executor %s not found", pluginID)
}

View file

@ -0,0 +1,873 @@
package pluginhost
import (
"context"
"fmt"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
type loadedPlugin struct {
id string
path string
version string
name string
registered bool
client pluginClient
}
type modelExecutor interface {
ExecuteModel(context.Context, handlers.ModelExecutionRequest) (handlers.ModelExecutionResponse, *interfaces.ErrorMessage)
ExecuteModelStream(context.Context, handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage)
}
type pluginUnloadTarget struct {
id string
name string
path string
version string
client pluginClient
}
type pluginLoadRequest struct {
result chan pluginLoadResult
cleanupStarted bool
}
type pluginLoadResult struct {
loaded *loadedPlugin
plugin pluginapi.Plugin
initialized bool
err error
}
type Host struct {
applyMu chan struct{}
mu sync.Mutex
loader pluginLoader
loaded map[string]*loadedPlugin
retired map[string][]*loadedPlugin
loading map[string]*pluginLoadRequest
fused map[string]string
pluginFileVersions map[string]string
activePluginVersions map[string]string
activePluginPaths map[string]string
cleanupFilesPending bool
runtimeConfig *config.Config
authManager *coreauth.Manager
modelExecutor modelExecutor
modelClientIDs map[string]struct{}
executorModelClientIDs map[string]struct{}
modelProviders map[string]string
modelRegistrations map[string]pluginModelRegistration
providerModels map[string][]*registryModelInfo
executorProviders map[string]struct{}
accessProviderKeys map[string]struct{}
commandLineFlags map[string]commandLineFlagRecord
commandLineHits map[string]struct{}
managementRoutes map[string]managementRouteRecord
resourceRoutes map[string]resourceRouteRecord
streams *streamBridge
httpStreams *hostHTTPStreamBridge
modelStreams *modelStreamBridge
callbackContexts *callbackContextRegistry
snapshot atomic.Value
}
func New() *Host {
h := &Host{
applyMu: make(chan struct{}, 1),
loader: defaultPluginLoader(),
loaded: make(map[string]*loadedPlugin),
retired: make(map[string][]*loadedPlugin),
loading: make(map[string]*pluginLoadRequest),
fused: make(map[string]string),
pluginFileVersions: make(map[string]string),
activePluginVersions: make(map[string]string),
activePluginPaths: make(map[string]string),
cleanupFilesPending: true,
modelClientIDs: make(map[string]struct{}),
executorModelClientIDs: make(map[string]struct{}),
modelProviders: make(map[string]string),
modelRegistrations: make(map[string]pluginModelRegistration),
providerModels: make(map[string][]*registryModelInfo),
executorProviders: make(map[string]struct{}),
accessProviderKeys: make(map[string]struct{}),
commandLineFlags: make(map[string]commandLineFlagRecord),
commandLineHits: make(map[string]struct{}),
managementRoutes: make(map[string]managementRouteRecord),
resourceRoutes: make(map[string]resourceRouteRecord),
streams: newStreamBridge(),
httpStreams: newHostHTTPStreamBridge(),
modelStreams: newModelStreamBridge(),
callbackContexts: newCallbackContextRegistry(),
}
h.snapshot.Store(emptySnapshot())
return h
}
func NewForTest(loader pluginLoader) *Host {
h := New()
h.loader = loader
return h
}
func (h *Host) SetModelExecutor(executor modelExecutor) {
if h == nil {
return
}
h.mu.Lock()
h.modelExecutor = executor
h.mu.Unlock()
}
func (h *Host) currentModelExecutor() modelExecutor {
if h == nil {
return nil
}
h.mu.Lock()
executor := h.modelExecutor
h.mu.Unlock()
return executor
}
func (h *Host) Snapshot() *Snapshot {
if h == nil {
return emptySnapshot()
}
raw := h.snapshot.Load()
if snap, ok := raw.(*Snapshot); ok && snap != nil {
return snap
}
return emptySnapshot()
}
// PluginLoaded reports whether a plugin dynamic library is still loaded by the host.
func (h *Host) PluginLoaded(id string) bool {
if h == nil {
return false
}
id = strings.TrimSpace(id)
if id == "" {
return false
}
h.mu.Lock()
defer h.mu.Unlock()
_, ok := h.loaded[id]
if ok {
return true
}
return len(h.retired[id]) > 0
}
// PluginBusy reports whether a plugin dynamic library is loaded or being loaded.
func (h *Host) PluginBusy(id string) bool {
if h == nil {
return false
}
id = strings.TrimSpace(id)
if id == "" {
return false
}
h.mu.Lock()
defer h.mu.Unlock()
if _, ok := h.loaded[id]; ok {
return true
}
if len(h.retired[id]) > 0 {
return true
}
_, ok := h.loading[id]
return ok
}
func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
if h == nil || !h.lockApply(ctx) {
return
}
defer h.unlockApply()
if ctx == nil {
ctx = context.Background()
}
if errContext := ctx.Err(); errContext != nil {
return
}
rc, errRuntimeConfig := runtimeConfigFromConfig(cfg)
if errRuntimeConfig != nil {
log.WithError(errRuntimeConfig).Error("failed to apply plugin runtime config")
return
}
h.mu.Lock()
h.runtimeConfig = cfg
h.mu.Unlock()
if !rc.Enabled {
h.mu.Lock()
h.managementRoutes = make(map[string]managementRouteRecord)
h.resourceRoutes = make(map[string]resourceRouteRecord)
h.rebuildActivePluginMapsLocked(nil)
h.snapshot.Store(emptySnapshot())
h.mu.Unlock()
h.refreshThinkingProviders(nil)
return
}
desiredVersions := desiredPluginVersions(rc.Items)
files, errSelect := selectPluginFiles(rc.Dir, desiredVersions)
if errSelect != nil {
log.Warnf("pluginhost: failed to select plugin files: %v", errSelect)
h.mu.Lock()
h.managementRoutes = make(map[string]managementRouteRecord)
h.resourceRoutes = make(map[string]resourceRouteRecord)
h.rebuildActivePluginMapsLocked(nil)
h.snapshot.Store(emptySnapshot())
h.mu.Unlock()
h.refreshThinkingProviders(nil)
return
}
files = h.withLoadedPluginFallbacks(files, rc.Items, desiredVersions)
records := make([]capabilityRecord, 0, len(files))
loadedFiles := make([]pluginFile, 0, len(files))
hotReloadLogs := make([]log.Fields, 0)
for _, file := range files {
item, ok := rc.Items[file.ID]
if !ok {
item = defaultRuntimeItemConfig(file.ID)
}
if !item.Enabled {
continue
}
h.mu.Lock()
lp := h.loaded[file.ID]
var replaced *loadedPlugin
if lp != nil && cleanPluginPath(lp.path) != cleanPluginPath(file.Path) {
replaced = lp
lp = nil
}
_, disabled := h.fused[file.ID]
h.mu.Unlock()
if disabled && replaced == nil {
continue
}
loadedNow := false
var hotReloadFields log.Fields
var plugin pluginapi.Plugin
registeredNow := false
if lp == nil {
request := &pluginLoadRequest{result: make(chan pluginLoadResult, 1)}
h.mu.Lock()
if _, loading := h.loading[file.ID]; loading {
h.mu.Unlock()
continue
}
h.loading[file.ID] = request
h.mu.Unlock()
h.startPluginLoad(ctx, file, item, request)
loadResult, completed := h.waitForPluginLoad(ctx, file.ID, request)
if !completed {
return
}
if loadResult.err != nil {
h.cleanupPluginLoad(file.ID, request, loadResult.loaded)
log.Warnf("pluginhost: failed to load plugin %s from %s: %v", file.ID, file.Path, loadResult.err)
continue
}
h.mu.Lock()
if h.loading[file.ID] != request {
h.mu.Unlock()
h.discardLoadedPlugin(loadResult.loaded)
return
}
if errContext := ctx.Err(); errContext != nil {
h.mu.Unlock()
h.cleanupPluginLoad(file.ID, request, loadResult.loaded)
return
}
delete(h.loading, file.ID)
lp = loadResult.loaded
if replaced != nil {
hotReloadFields = pluginHotReloadLogFields(file.ID, file.Version, file.Path, replaced.version, replaced.path)
h.retireLoadedPluginLocked(replaced)
delete(h.fused, file.ID)
h.removePluginRuntimeStateLocked(file.ID)
}
h.loaded[file.ID] = lp
loadedNow = true
plugin = loadResult.plugin
registeredNow = loadResult.initialized
h.mu.Unlock()
log.WithFields(pluginLogFields(file.ID, "", file.Version, file.Path)).Info("pluginhost: plugin loaded")
}
if !registeredNow {
if loadedNow {
continue
}
var okCall bool
plugin, okCall = h.callRegister(ctx, lp, item)
if !okCall {
continue
}
}
plugin.Metadata = clonePluginMetadata(plugin.Metadata)
h.mu.Lock()
if lp != nil {
lp.name = strings.TrimSpace(plugin.Metadata.Name)
if strings.TrimSpace(lp.version) == "" {
lp.version = strings.TrimSpace(plugin.Metadata.Version)
}
}
h.mu.Unlock()
if loadedNow {
log.WithFields(pluginLogFieldsFromMetadata(file.ID, plugin.Metadata, file.Path)).Info("pluginhost: plugin registered")
}
if hotReloadFields != nil {
hotReloadLogs = append(hotReloadLogs, hotReloadFields)
}
records = append(records, capabilityRecord{
id: file.ID,
path: file.Path,
version: file.Version,
priority: item.Priority,
meta: plugin.Metadata,
plugin: plugin,
})
loadedFiles = append(loadedFiles, file)
}
sortRecords(records)
h.mu.Lock()
cleanupFiles := h.cleanupFilesPending
if len(loadedFiles) > 0 {
h.cleanupFilesPending = false
}
h.rebuildActivePluginMapsLocked(records)
h.snapshot.Store(&Snapshot{enabled: true, records: records})
h.mu.Unlock()
h.refreshThinkingProviders(records)
for _, fields := range hotReloadLogs {
log.WithFields(fields).Info("pluginhost: plugin hot reloaded")
}
if cleanupFiles && len(loadedFiles) > 0 {
if errCleanup := cleanupUnselectedPluginFiles(rc.Dir, loadedFiles); errCleanup != nil {
log.Warnf("pluginhost: failed to clean old plugin files: %v", errCleanup)
}
}
}
func (h *Host) startPluginLoad(ctx context.Context, file pluginFile, item runtimeItemConfig, request *pluginLoadRequest) {
if h == nil || request == nil || request.result == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
go func() {
client, errOpen := h.loader.Open(file, h)
if errOpen != nil {
request.result <- pluginLoadResult{err: errOpen}
return
}
if client == nil {
request.result <- pluginLoadResult{err: fmt.Errorf("plugin loader returned nil client")}
return
}
loaded := &loadedPlugin{
id: file.ID,
path: file.Path,
version: file.Version,
client: newGuardedPluginClient(client),
}
plugin, okCall := h.callRegister(ctx, loaded, item)
request.result <- pluginLoadResult{loaded: loaded, plugin: plugin, initialized: okCall}
}()
}
func (h *Host) waitForPluginLoad(ctx context.Context, id string, request *pluginLoadRequest) (pluginLoadResult, bool) {
if h == nil || request == nil || request.result == nil {
return pluginLoadResult{}, false
}
if ctx == nil {
ctx = context.Background()
}
select {
case result := <-request.result:
return result, true
case <-ctx.Done():
h.cleanupCanceledPluginLoad(id, request)
return pluginLoadResult{}, false
}
}
func (h *Host) cleanupCanceledPluginLoad(id string, request *pluginLoadRequest) {
if h == nil || request == nil || request.result == nil {
return
}
h.mu.Lock()
if h.loading[id] != request || request.cleanupStarted {
h.mu.Unlock()
return
}
request.cleanupStarted = true
h.mu.Unlock()
go func() {
result := <-request.result
h.finishPluginLoadCleanup(id, request, result.loaded)
}()
}
// cleanupPluginLoad retains the matching load token until the client has physically
// shut down, preventing a replacement ApplyConfig from opening a second client.
func (h *Host) cleanupPluginLoad(id string, request *pluginLoadRequest, loaded *loadedPlugin) {
if h == nil || request == nil {
return
}
h.mu.Lock()
if h.loading[id] != request || request.cleanupStarted {
h.mu.Unlock()
return
}
request.cleanupStarted = true
h.mu.Unlock()
h.finishPluginLoadCleanup(id, request, loaded)
}
func (h *Host) finishPluginLoadCleanup(id string, request *pluginLoadRequest, loaded *loadedPlugin) {
go func() {
h.discardLoadedPlugin(loaded)
h.clearLoadingRequest(id, request)
}()
}
func (h *Host) clearLoadingRequest(id string, request *pluginLoadRequest) {
if h == nil || request == nil {
return
}
h.mu.Lock()
if h.loading[id] == request {
delete(h.loading, id)
}
h.mu.Unlock()
}
func (h *Host) discardLoadedPlugin(loaded *loadedPlugin) {
if loaded == nil || loaded.client == nil {
return
}
shutdownPluginClient(context.Background(), loaded.client)
}
func (h *Host) withLoadedPluginFallbacks(files []pluginFile, items map[string]runtimeItemConfig, desired map[string]string) []pluginFile {
if h == nil || len(desired) == 0 {
return files
}
selected := make(map[string]struct{}, len(files))
for _, file := range files {
id := strings.TrimSpace(file.ID)
if id != "" {
selected[id] = struct{}{}
}
}
ids := make([]string, 0, len(desired))
for id := range desired {
ids = append(ids, id)
}
sort.Strings(ids)
h.mu.Lock()
defer h.mu.Unlock()
for _, id := range ids {
if _, ok := selected[id]; ok {
continue
}
if item, ok := items[id]; ok && !item.Enabled {
continue
}
lp := h.loaded[id]
if lp == nil || strings.TrimSpace(lp.path) == "" {
continue
}
files = append(files, pluginFile{
ID: id,
Path: lp.path,
Version: strings.TrimSpace(lp.version),
})
selected[id] = struct{}{}
}
return files
}
// UnloadPlugin removes one plugin from the active runtime and closes its dynamic library.
func (h *Host) UnloadPlugin(id string) bool {
return h.UnloadPluginContext(context.Background(), id)
}
// UnloadPluginContext detaches a plugin from the runtime before waiting for its
// active calls. Physical client cleanup continues after cancellation if needed.
func (h *Host) UnloadPluginContext(ctx context.Context, id string) bool {
if h == nil {
return false
}
id = strings.TrimSpace(id)
if id == "" || !h.lockApply(ctx) {
return false
}
defer h.unlockApply()
targets := make([]pluginUnloadTarget, 0)
h.mu.Lock()
lp := h.loaded[id]
if lp != nil {
targets = append(targets, pluginUnloadTarget{id: lp.id, name: lp.name, path: lp.path, version: lp.version, client: lp.client})
}
for _, retired := range h.retired[id] {
if retired == nil {
continue
}
targets = append(targets, pluginUnloadTarget{id: retired.id, name: retired.name, path: retired.path, version: retired.version, client: retired.client})
}
if len(targets) == 0 {
h.mu.Unlock()
return false
}
delete(h.loaded, id)
delete(h.retired, id)
delete(h.fused, id)
delete(h.activePluginVersions, id)
delete(h.activePluginPaths, id)
for _, target := range targets {
delete(h.pluginFileVersions, cleanPluginPath(target.path))
}
records, enabled := h.snapshotWithoutPluginLocked(id)
h.removePluginRuntimeStateLocked(id)
h.snapshot.Store(&Snapshot{enabled: enabled, records: records})
h.mu.Unlock()
h.refreshThinkingProviders(records)
h.RegisterFrontendAuthProviders()
for _, target := range targets {
if target.client != nil {
shutdownPluginClient(ctx, target.client)
}
log.WithFields(pluginLogFields(target.id, target.name, target.version, target.path)).Info("pluginhost: plugin unloaded")
}
return true
}
// ShutdownAll removes active plugin capabilities and closes all loaded dynamic libraries.
func (h *Host) ShutdownAll() {
h.ShutdownAllContext(context.Background())
}
// ShutdownAllContext detaches all plugin runtime state without waiting beyond ctx
// for active plugin calls to complete.
func (h *Host) ShutdownAllContext(ctx context.Context) {
if h == nil || !h.lockApply(ctx) {
return
}
defer h.unlockApply()
targets := make([]pluginUnloadTarget, 0)
var loading map[string]*pluginLoadRequest
h.mu.Lock()
loading = make(map[string]*pluginLoadRequest, len(h.loading))
for id, request := range h.loading {
loading[id] = request
}
for _, lp := range h.loaded {
if lp == nil || lp.client == nil {
continue
}
targets = append(targets, pluginUnloadTarget{
id: lp.id,
name: lp.name,
path: lp.path,
version: lp.version,
client: lp.client,
})
}
for _, retiredPlugins := range h.retired {
for _, lp := range retiredPlugins {
if lp == nil || lp.client == nil {
continue
}
targets = append(targets, pluginUnloadTarget{
id: lp.id,
name: lp.name,
path: lp.path,
version: lp.version,
client: lp.client,
})
}
}
h.loaded = make(map[string]*loadedPlugin)
h.retired = make(map[string][]*loadedPlugin)
h.modelClientIDs = make(map[string]struct{})
h.executorModelClientIDs = make(map[string]struct{})
h.modelProviders = make(map[string]string)
h.modelRegistrations = make(map[string]pluginModelRegistration)
h.providerModels = make(map[string][]*registryModelInfo)
h.executorProviders = make(map[string]struct{})
h.commandLineFlags = make(map[string]commandLineFlagRecord)
h.commandLineHits = make(map[string]struct{})
h.managementRoutes = make(map[string]managementRouteRecord)
h.resourceRoutes = make(map[string]resourceRouteRecord)
h.pluginFileVersions = make(map[string]string)
h.activePluginVersions = make(map[string]string)
h.activePluginPaths = make(map[string]string)
h.snapshot.Store(emptySnapshot())
h.mu.Unlock()
h.refreshThinkingProviders(nil)
h.RegisterFrontendAuthProviders()
for id, request := range loading {
h.cleanupCanceledPluginLoad(id, request)
}
for _, target := range targets {
shutdownPluginClient(ctx, target.client)
log.WithFields(pluginLogFields(target.id, target.name, target.version, target.path)).Info("pluginhost: plugin unloaded")
}
}
func (h *Host) lockApply(ctx context.Context) bool {
if h == nil {
return false
}
if ctx == nil {
ctx = context.Background()
}
select {
case h.applyMu <- struct{}{}:
return true
default:
}
select {
case h.applyMu <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
func (h *Host) unlockApply() {
<-h.applyMu
}
func shutdownPluginClient(ctx context.Context, client pluginClient) {
if client == nil {
return
}
if guarded, ok := client.(*guardedPluginClient); ok {
guarded.ShutdownContext(ctx)
return
}
client.Shutdown()
}
func cleanPluginPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
return filepath.Clean(path)
}
func (h *Host) retireLoadedPluginLocked(lp *loadedPlugin) {
if h == nil || lp == nil {
return
}
h.retired[lp.id] = append(h.retired[lp.id], lp)
}
func (h *Host) recordCurrent(record capabilityRecord) bool {
return h.pluginIdentityCurrent(record.id, record.path, record.version)
}
func (h *Host) pluginIdentityCurrent(id string, path string, version string) bool {
if h == nil {
return false
}
version = strings.TrimSpace(version)
h.mu.Lock()
defer h.mu.Unlock()
id = strings.TrimSpace(id)
if id == "" {
return false
}
path = cleanPluginPath(path)
if path == "" || h.activePluginPaths[id] != path {
return false
}
activePathVersion, okVersion := h.pluginFileVersions[path]
if !okVersion || activePathVersion != version {
return false
}
return h.activePluginVersions[id] == version
}
func (h *Host) snapshotWithoutPluginLocked(id string) ([]capabilityRecord, bool) {
raw := h.snapshot.Load()
snap, _ := raw.(*Snapshot)
if snap == nil || len(snap.records) == 0 {
return nil, snap != nil && snap.enabled
}
records := make([]capabilityRecord, 0, len(snap.records))
for _, record := range snap.records {
if record.id == id {
continue
}
records = append(records, record)
}
return records, snap.enabled
}
func (h *Host) removePluginRuntimeStateLocked(id string) {
for key, record := range h.managementRoutes {
if record.pluginID == id {
delete(h.managementRoutes, key)
}
}
for key, record := range h.resourceRoutes {
if record.pluginID == id {
delete(h.resourceRoutes, key)
}
}
for name, record := range h.commandLineFlags {
if record.pluginID == id {
delete(h.commandLineFlags, name)
delete(h.commandLineHits, name)
}
}
if registration, ok := h.modelRegistrations[id]; ok {
delete(h.providerModels, registration.provider)
}
delete(h.modelProviders, id)
delete(h.modelRegistrations, id)
}
func (h *Host) rebuildActivePluginMapsLocked(records []capabilityRecord) {
h.pluginFileVersions = make(map[string]string, len(records))
h.activePluginVersions = make(map[string]string, len(records))
h.activePluginPaths = make(map[string]string, len(records))
for _, record := range records {
id := strings.TrimSpace(record.id)
path := cleanPluginPath(record.path)
if id == "" || path == "" {
continue
}
h.pluginFileVersions[path] = strings.TrimSpace(record.version)
h.activePluginVersions[id] = strings.TrimSpace(record.version)
h.activePluginPaths[id] = path
}
}
func (h *Host) callRegister(ctx context.Context, lp *loadedPlugin, item runtimeItemConfig) (pluginapi.Plugin, bool) {
if lp == nil {
return pluginapi.Plugin{}, false
}
method := pluginabi.MethodPluginRegister
h.mu.Lock()
registered := lp.registered
h.mu.Unlock()
if registered {
method = pluginabi.MethodPluginReconfigure
}
plugin, okCall := h.safePluginCall(ctx, lp.id, method, func() pluginapi.Plugin {
plugin, errRegister := registerRPCPlugin(ctx, h, lp.id, lp.client, method, item.ConfigYAML)
if errRegister != nil {
log.Warnf("pluginhost: plugin %s %s failed: %v", lp.id, method, errRegister)
return pluginapi.Plugin{}
}
return plugin
})
if !okCall {
return pluginapi.Plugin{}, false
}
h.mu.Lock()
lp.registered = true
h.mu.Unlock()
if !validPlugin(plugin) {
log.Warnf("pluginhost: plugin %s returned invalid metadata or no capabilities", lp.id)
return pluginapi.Plugin{}, false
}
return plugin, true
}
func (h *Host) safePluginCall(ctx context.Context, id, method string, fn func() pluginapi.Plugin) (out pluginapi.Plugin, ok bool) {
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(id, method, recovered)
out = pluginapi.Plugin{}
ok = false
}
}()
if ctx != nil {
select {
case <-ctx.Done():
return pluginapi.Plugin{}, false
default:
}
}
return fn(), true
}
func validPlugin(plugin pluginapi.Plugin) bool {
if strings.TrimSpace(plugin.Metadata.Name) == "" {
return false
}
if strings.TrimSpace(plugin.Metadata.Version) == "" {
return false
}
if strings.TrimSpace(plugin.Metadata.Author) == "" {
return false
}
if strings.TrimSpace(plugin.Metadata.GitHubRepository) == "" {
return false
}
caps := plugin.Capabilities
return caps.ModelRegistrar != nil ||
caps.ModelProvider != nil ||
caps.AuthProvider != nil ||
caps.FrontendAuthProvider != nil ||
caps.Scheduler != nil ||
caps.ModelRouter != nil ||
caps.Executor != nil ||
caps.RequestTranslator != nil ||
caps.RequestNormalizer != nil ||
caps.RequestInterceptor != nil ||
caps.RequestLifecyclePlugin != nil ||
caps.ResponseTranslator != nil ||
caps.ResponseBeforeTranslator != nil ||
caps.ResponseAfterTranslator != nil ||
caps.ResponseInterceptor != nil ||
caps.StreamChunkInterceptor != nil ||
caps.ThinkingApplier != nil ||
caps.UsagePlugin != nil ||
caps.CommandLinePlugin != nil ||
caps.ManagementAPI != nil
}
func typeName(v any) string {
return fmt.Sprintf("%T", v)
}

View file

@ -0,0 +1,356 @@
package pluginhost
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
type rpcHostHTTPRequest struct {
HTTPClientID string `json:"http_client_id,omitempty"`
HostCallbackID string `json:"host_callback_id,omitempty"`
Method string `json:"method,omitempty"`
URL string `json:"url,omitempty"`
Headers httpHeader `json:"headers,omitempty"`
Body []byte `json:"body,omitempty"`
Request *httpRequest `json:"request,omitempty"`
}
type httpHeader map[string][]string
type httpRequest struct {
Method string `json:"method,omitempty"`
URL string `json:"url,omitempty"`
Headers httpHeader `json:"headers,omitempty"`
Body []byte `json:"body,omitempty"`
}
type rpcHostHTTPStreamResponse struct {
StatusCode int `json:"status_code"`
Headers httpHeader `json:"headers,omitempty"`
StreamID string `json:"stream_id,omitempty"`
Chunks []pluginapi.HTTPStreamChunk `json:"chunks,omitempty"`
}
type rpcHostHTTPStreamReadRequest struct {
StreamID string `json:"stream_id"`
}
type rpcHostHTTPStreamReadResponse struct {
Payload []byte `json:"payload,omitempty"`
Error string `json:"error,omitempty"`
Done bool `json:"done,omitempty"`
}
type rpcHostHTTPStreamCloseRequest struct {
StreamID string `json:"stream_id"`
}
type rpcHostLogRequest struct {
HostCallbackID string `json:"host_callback_id,omitempty"`
Level string `json:"level,omitempty"`
Message string `json:"message,omitempty"`
Fields map[string]any `json:"fields,omitempty"`
}
type rpcHostModelExecutionRequest struct {
pluginapi.HostModelExecutionRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type dynamicHostCallbackEntry struct {
host *Host
pluginID string
}
type hostCallbackPluginIDKey struct{}
func withHostCallbackPluginID(ctx context.Context, pluginID string) context.Context {
pluginID = strings.TrimSpace(pluginID)
if pluginID == "" {
if ctx == nil {
return context.Background()
}
return ctx
}
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, hostCallbackPluginIDKey{}, pluginID)
}
func hostCallbackPluginIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
pluginID, _ := ctx.Value(hostCallbackPluginIDKey{}).(string)
return strings.TrimSpace(pluginID)
}
func (h *Host) callFromPlugin(ctx context.Context, method string, request []byte) ([]byte, error) {
switch method {
case pluginabi.MethodHostModelExecute:
return h.callHostModelExecute(ctx, request)
case pluginabi.MethodHostModelExecuteStream:
return h.callHostModelExecuteStream(ctx, request)
case pluginabi.MethodHostModelStreamRead:
return h.callHostModelStreamRead(ctx, request)
case pluginabi.MethodHostModelStreamClose:
return h.callHostModelStreamClose(request)
case pluginabi.MethodHostHTTPDo:
return h.callHostHTTPDo(ctx, request)
case pluginabi.MethodHostHTTPDoStream:
return h.callHostHTTPDoStream(ctx, request)
case pluginabi.MethodHostHTTPStreamRead:
return h.callHostHTTPStreamRead(ctx, request)
case pluginabi.MethodHostHTTPStreamClose:
return h.callHostHTTPStreamClose(request)
case pluginabi.MethodHostStreamEmit:
return h.callHostStreamEmit(ctx, request)
case pluginabi.MethodHostStreamClose:
return h.callHostStreamClose(request)
case pluginabi.MethodHostLog:
return h.callHostLog(ctx, request)
case pluginabi.MethodHostAuthList:
return h.callHostAuthList(ctx, request)
case pluginabi.MethodHostAuthGet:
return h.callHostAuthGet(ctx, request)
case pluginabi.MethodHostAuthGetRuntime:
return h.callHostAuthGetRuntime(ctx, request)
case pluginabi.MethodHostAuthSave:
return h.callHostAuthSave(ctx, request)
default:
return nil, fmt.Errorf("unsupported host callback %s", method)
}
}
func (h *Host) callbackCallerPluginID(ctx context.Context, callbackID string) string {
if pluginID := hostCallbackPluginIDFromContext(ctx); pluginID != "" {
return pluginID
}
return h.callbackContextPluginID(callbackID)
}
func (h *Host) callHostHTTPDo(ctx context.Context, request []byte) ([]byte, error) {
httpReq, callbackID, errDecode := decodeHostHTTPRequestWithCallbackID(request)
if errDecode != nil {
return nil, errDecode
}
ctx = h.resolveCallbackContext(callbackID, ctx)
resp, errDo := h.newHTTPClient(nil).Do(ctx, httpReq)
if errDo != nil {
return nil, errDo
}
return marshalRPCResult(resp)
}
func (h *Host) callHostHTTPDoStream(ctx context.Context, request []byte) ([]byte, error) {
httpReq, callbackID, errDecode := decodeHostHTTPRequestWithCallbackID(request)
if errDecode != nil {
return nil, errDecode
}
ctx = h.resolveCallbackContext(callbackID, ctx)
if ctx == nil {
ctx = context.Background()
}
streamCtx, cancel := context.WithCancel(ctx)
resp, errDo := h.newHTTPClient(nil).DoStream(streamCtx, httpReq)
if errDo != nil {
cancel()
return nil, errDo
}
streamID := ""
if h != nil && h.httpStreams != nil {
streamID = h.httpStreams.open(resp.Chunks, cancel)
}
if streamID == "" {
cancel()
return nil, fmt.Errorf("host http stream bridge is unavailable")
}
return marshalRPCResult(rpcHostHTTPStreamResponse{
StatusCode: resp.StatusCode,
Headers: httpHeader(resp.Headers),
StreamID: streamID,
})
}
func (h *Host) callHostHTTPStreamRead(ctx context.Context, request []byte) ([]byte, error) {
var req rpcHostHTTPStreamReadRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host http stream read request: %w", errUnmarshal)
}
if h == nil || h.httpStreams == nil {
return nil, fmt.Errorf("host http stream bridge is unavailable")
}
chunk, done, errRead := h.httpStreams.read(ctx, req.StreamID)
if errRead != nil {
return nil, errRead
}
resp := rpcHostHTTPStreamReadResponse{
Payload: append([]byte(nil), chunk.Payload...),
Done: done,
}
if chunk.Err != nil {
resp.Error = chunk.Err.Error()
resp.Done = true
}
return marshalRPCResult(resp)
}
func (h *Host) callHostHTTPStreamClose(request []byte) ([]byte, error) {
var req rpcHostHTTPStreamCloseRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host http stream close request: %w", errUnmarshal)
}
if h != nil && h.httpStreams != nil {
h.httpStreams.close(req.StreamID)
}
return marshalRPCResult(rpcEmptyResponse{})
}
func decodeHostHTTPRequest(raw []byte) (pluginapi.HTTPRequest, error) {
httpReq, _, errDecode := decodeHostHTTPRequestWithCallbackID(raw)
return httpReq, errDecode
}
func decodeHostHTTPRequestWithCallbackID(raw []byte) (pluginapi.HTTPRequest, string, error) {
var req rpcHostHTTPRequest
if errUnmarshal := json.Unmarshal(raw, &req); errUnmarshal != nil {
return pluginapi.HTTPRequest{}, "", fmt.Errorf("decode host http request: %w", errUnmarshal)
}
if req.Request != nil {
return pluginapi.HTTPRequest{
Method: req.Request.Method,
URL: req.Request.URL,
Headers: map[string][]string(req.Request.Headers),
Body: append([]byte(nil), req.Request.Body...),
}, req.HostCallbackID, nil
}
return pluginapi.HTTPRequest{
Method: req.Method,
URL: req.URL,
Headers: map[string][]string(req.Headers),
Body: append([]byte(nil), req.Body...),
}, req.HostCallbackID, nil
}
func (h *Host) callHostStreamEmit(ctx context.Context, request []byte) ([]byte, error) {
var req rpcStreamEmitRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode stream emit request: %w", errUnmarshal)
}
chunk := pluginapi.ExecutorStreamChunk{Payload: append([]byte(nil), req.Payload...)}
if req.Error != "" {
chunk.Err = fmt.Errorf("%s", req.Error)
}
if errEmit := h.streams.emit(ctx, req.StreamID, chunk); errEmit != nil {
return nil, errEmit
}
return marshalRPCResult(rpcEmptyResponse{})
}
func (h *Host) callHostStreamClose(request []byte) ([]byte, error) {
var req rpcStreamCloseRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode stream close request: %w", errUnmarshal)
}
h.streams.close(req.StreamID, req.Error)
return marshalRPCResult(rpcEmptyResponse{})
}
func (h *Host) callHostModelExecute(ctx context.Context, request []byte) ([]byte, error) {
var req rpcHostModelExecutionRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host model execution request: %w", errUnmarshal)
}
if req.Stream {
return nil, fmt.Errorf("host.model.execute requires stream=false")
}
executor := h.currentModelExecutor()
if executor == nil {
return nil, fmt.Errorf("host model executor is unavailable")
}
skipPluginID := h.callbackCallerPluginID(ctx, req.HostCallbackID)
ctx = h.resolveCallbackContext(req.HostCallbackID, ctx)
resp, errMsg := executor.ExecuteModel(ctx, modelExecutionRequestFromPlugin(req.HostModelExecutionRequest, skipPluginID))
if errMsg != nil {
return nil, modelExecutionError(errMsg)
}
return marshalRPCResult(pluginapi.HostModelExecutionResponse{
StatusCode: resp.StatusCode,
Headers: cloneHeader(resp.Headers),
Body: append([]byte(nil), resp.Body...),
})
}
func modelExecutionRequestFromPlugin(req pluginapi.HostModelExecutionRequest, skipPluginID string) handlers.ModelExecutionRequest {
return handlers.ModelExecutionRequest{
EntryProtocol: req.EntryProtocol,
ExitProtocol: req.ExitProtocol,
Model: req.Model,
Stream: req.Stream,
Body: append([]byte(nil), req.Body...),
Headers: cloneHeader(req.Headers),
Query: cloneValues(req.Query),
Alt: req.Alt,
SkipInterceptorPluginID: skipPluginID,
SkipRouterPluginID: skipPluginID,
}
}
func modelExecutionError(errMsg *interfaces.ErrorMessage) error {
if errMsg == nil {
return nil
}
if errMsg.Error != nil {
return errMsg.Error
}
if errMsg.StatusCode > 0 {
return fmt.Errorf("model execution failed with status %d", errMsg.StatusCode)
}
return fmt.Errorf("model execution failed")
}
func (h *Host) callHostLog(ctx context.Context, request []byte) ([]byte, error) {
var req rpcHostLogRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host log request: %w", errUnmarshal)
}
ctx = h.resolveCallbackContext(req.HostCallbackID, ctx)
message := strings.TrimSpace(req.Message)
if message == "" {
message = "plugin log"
}
fields := log.Fields{}
for key, value := range req.Fields {
key = strings.TrimSpace(key)
if key != "" {
fields[key] = value
}
}
if requestID := logging.GetRequestID(ctx); requestID != "" {
fields["request_id"] = requestID
}
entry := log.WithFields(fields)
switch strings.ToLower(strings.TrimSpace(req.Level)) {
case "trace":
entry.Trace(message)
case "info":
entry.Info(message)
case "warn", "warning":
entry.Warn(message)
case "error":
entry.Error(message)
default:
entry.Debug(message)
}
return marshalRPCResult(rpcEmptyResponse{})
}

View file

@ -0,0 +1,752 @@
package pluginhost
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
type fakeHostModelExecutor struct {
executeModel func(context.Context, handlers.ModelExecutionRequest) (handlers.ModelExecutionResponse, *interfaces.ErrorMessage)
executeModelStream func(context.Context, handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage)
}
func (e *fakeHostModelExecutor) ExecuteModel(ctx context.Context, req handlers.ModelExecutionRequest) (handlers.ModelExecutionResponse, *interfaces.ErrorMessage) {
return e.executeModel(ctx, req)
}
func (e *fakeHostModelExecutor) ExecuteModelStream(ctx context.Context, req handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage) {
return e.executeModelStream(ctx, req)
}
func TestHostHTTPDoCallbackUsesHostHTTPClient(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
}
w.Header().Set("X-Test", "ok")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
req := pluginapi.HTTPRequest{
Method: http.MethodPost,
URL: server.URL,
Body: []byte(`{"request":true}`),
}
rawReq, errMarshal := json.Marshal(req)
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
rawResp, errCall := New().callFromPlugin(context.Background(), pluginabi.MethodHostHTTPDo, rawReq)
if errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HTTPResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.StatusCode != http.StatusOK || string(resp.Body) != `{"ok":true}` {
t.Fatalf("response = %#v, want status 200 body", resp)
}
if resp.Headers.Get("X-Test") != "ok" {
t.Fatalf("X-Test = %q, want ok", resp.Headers.Get("X-Test"))
}
}
func TestHostHTTPDoCallbackRestoresRegisteredRequestContext(t *testing.T) {
gin.SetMode(gin.TestMode)
ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx := context.WithValue(context.Background(), "gin", ginCtx)
host := New()
host.mu.Lock()
host.runtimeConfig = &config.Config{SDKConfig: config.SDKConfig{RequestLog: true}}
host.mu.Unlock()
callbackID, closeCallback := host.openCallbackContext(ctx)
defer closeCallback()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Context().Err() != nil {
t.Fatalf("request context error = %v", r.Context().Err())
}
w.Header().Set("X-Upstream", "ok")
_, _ = w.Write([]byte("upstream-body"))
}))
defer server.Close()
rawReq, errMarshal := json.Marshal(rpcHostHTTPRequest{
HostCallbackID: callbackID,
Method: http.MethodPost,
URL: server.URL,
Body: []byte(`{"request":true}`),
})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
if _, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostHTTPDo, rawReq); errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
rawAPIRequest, okRequest := ginCtx.Get("API_REQUEST")
if !okRequest {
t.Fatal("API_REQUEST was not captured on the original Gin context")
}
apiRequest, _ := rawAPIRequest.([]byte)
if !bytes.Contains(apiRequest, []byte("=== API REQUEST 1 ===")) || !bytes.Contains(apiRequest, []byte(`{"request":true}`)) {
t.Fatalf("API_REQUEST = %q, want upstream request details", apiRequest)
}
rawAPIResponse, okResponse := ginCtx.Get("API_RESPONSE")
if !okResponse {
t.Fatal("API_RESPONSE was not captured on the original Gin context")
}
apiResponse, _ := rawAPIResponse.([]byte)
if !bytes.Contains(apiResponse, []byte("=== API RESPONSE 1 ===")) || !bytes.Contains(apiResponse, []byte("upstream-body")) {
t.Fatalf("API_RESPONSE = %q, want upstream response details", apiResponse)
}
}
func TestHostHTTPDoStreamCallbackReturnsBeforeUpstreamCompletes(t *testing.T) {
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("first"))
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
<-release
_, _ = w.Write([]byte("second"))
}))
defer server.Close()
defer close(release)
rawReq, errMarshal := json.Marshal(pluginapi.HTTPRequest{
Method: http.MethodGet,
URL: server.URL,
})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
type callResult struct {
raw []byte
err error
}
done := make(chan callResult, 1)
host := New()
go func() {
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostHTTPDoStream, rawReq)
done <- callResult{raw: rawResp, err: errCall}
}()
var result callResult
select {
case result = <-done:
case <-time.After(time.Second):
t.Fatal("host.http.do_stream waited for the whole upstream response")
}
if result.err != nil {
t.Fatalf("callFromPlugin() error = %v", result.err)
}
resp, errDecode := decodeRPCEnvelope[rpcHostHTTPStreamResponse](result.raw)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.StreamID == "" {
t.Fatalf("stream id is empty: %#v", resp)
}
readReq, errMarshal := json.Marshal(rpcHostHTTPStreamReadRequest{StreamID: resp.StreamID})
if errMarshal != nil {
t.Fatalf("marshal read request: %v", errMarshal)
}
rawRead, errRead := host.callFromPlugin(context.Background(), pluginabi.MethodHostHTTPStreamRead, readReq)
if errRead != nil {
t.Fatalf("read callback error = %v", errRead)
}
chunk, errDecode := decodeRPCEnvelope[rpcHostHTTPStreamReadResponse](rawRead)
if errDecode != nil {
t.Fatalf("decode read response: %v", errDecode)
}
if string(chunk.Payload) != "first" || chunk.Done || chunk.Error != "" {
t.Fatalf("read chunk = %#v, want first payload", chunk)
}
closeReq, errMarshal := json.Marshal(rpcHostHTTPStreamCloseRequest{StreamID: resp.StreamID})
if errMarshal != nil {
t.Fatalf("marshal close request: %v", errMarshal)
}
if _, errClose := host.callFromPlugin(context.Background(), pluginabi.MethodHostHTTPStreamClose, closeReq); errClose != nil {
t.Fatalf("close callback error = %v", errClose)
}
}
func TestHostStreamCallbacksEmitAndClose(t *testing.T) {
host := New()
streamID, chunks, cleanup := host.streams.open(context.Background())
defer cleanup()
emitReq, errMarshal := json.Marshal(rpcStreamEmitRequest{StreamID: streamID, Payload: []byte("chunk")})
if errMarshal != nil {
t.Fatalf("marshal emit request: %v", errMarshal)
}
if _, errEmit := host.callFromPlugin(context.Background(), pluginabi.MethodHostStreamEmit, emitReq); errEmit != nil {
t.Fatalf("emit callback error = %v", errEmit)
}
closeReq, errMarshal := json.Marshal(rpcStreamCloseRequest{StreamID: streamID})
if errMarshal != nil {
t.Fatalf("marshal close request: %v", errMarshal)
}
if _, errClose := host.callFromPlugin(context.Background(), pluginabi.MethodHostStreamClose, closeReq); errClose != nil {
t.Fatalf("close callback error = %v", errClose)
}
chunk, ok := <-chunks
if !ok {
t.Fatalf("stream closed before chunk")
}
if string(chunk.Payload) != "chunk" || chunk.Err != nil {
t.Fatalf("chunk = %#v, want payload chunk", chunk)
}
if _, ok = <-chunks; ok {
t.Fatalf("stream remains open after close")
}
}
func TestHostModelExecuteCallback(t *testing.T) {
host := New()
var got handlers.ModelExecutionRequest
host.SetModelExecutor(&fakeHostModelExecutor{
executeModel: func(ctx context.Context, req handlers.ModelExecutionRequest) (handlers.ModelExecutionResponse, *interfaces.ErrorMessage) {
got = req
return handlers.ModelExecutionResponse{
StatusCode: http.StatusAccepted,
Headers: http.Header{"X-Model": []string{"ok"}},
Body: []byte(`{"response":true}`),
}, nil
},
})
rawReq, errMarshal := json.Marshal(rpcHostModelExecutionRequest{
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
EntryProtocol: "openai",
ExitProtocol: "claude",
Model: "model-1",
Body: []byte(`{"request":true}`),
Headers: http.Header{"X-Request": []string{"yes"}},
Query: url.Values{"alt": []string{"sse"}},
Alt: "raw",
},
})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelExecute, rawReq)
if errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostModelExecutionResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.StatusCode != http.StatusAccepted || string(resp.Body) != `{"response":true}` {
t.Fatalf("response = %#v, want accepted body", resp)
}
if resp.Headers.Get("X-Model") != "ok" {
t.Fatalf("X-Model = %q, want ok", resp.Headers.Get("X-Model"))
}
if got.EntryProtocol != "openai" || got.ExitProtocol != "claude" || got.Model != "model-1" || got.Stream {
t.Fatalf("request protocols/model/stream = %#v", got)
}
if string(got.Body) != `{"request":true}` {
t.Fatalf("request body = %q, want original body", got.Body)
}
if got.Headers.Get("X-Request") != "yes" {
t.Fatalf("request header = %q, want yes", got.Headers.Get("X-Request"))
}
if got.Query.Get("alt") != "sse" {
t.Fatalf("query alt = %q, want sse", got.Query.Get("alt"))
}
if got.Alt != "raw" {
t.Fatalf("alt = %q, want raw", got.Alt)
}
}
func TestHostModelExecuteCallbackCarriesCallerPluginSkipID(t *testing.T) {
host := New()
var got handlers.ModelExecutionRequest
host.SetModelExecutor(&fakeHostModelExecutor{
executeModel: func(ctx context.Context, req handlers.ModelExecutionRequest) (handlers.ModelExecutionResponse, *interfaces.ErrorMessage) {
got = req
return handlers.ModelExecutionResponse{StatusCode: http.StatusOK, Body: []byte(`{"ok":true}`)}, nil
},
})
callbackID, closeCallback := host.openCallbackContextForPlugin(context.Background(), "origin-plugin")
defer closeCallback()
rawReq, errMarshal := json.Marshal(rpcHostModelExecutionRequest{
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
EntryProtocol: "openai",
ExitProtocol: "openai",
Model: "model-1",
Body: []byte(`{"request":true}`),
},
HostCallbackID: callbackID,
})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
if _, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelExecute, rawReq); errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
if got.SkipInterceptorPluginID != "origin-plugin" {
t.Fatalf("SkipInterceptorPluginID = %q, want origin-plugin", got.SkipInterceptorPluginID)
}
if got.SkipRouterPluginID != "origin-plugin" {
t.Fatalf("SkipRouterPluginID = %q, want origin-plugin", got.SkipRouterPluginID)
}
}
func TestHostModelStreamClosesWithCallbackScope(t *testing.T) {
host := New()
ctxSeen := make(chan context.Context, 1)
host.SetModelExecutor(&fakeHostModelExecutor{
executeModelStream: func(ctx context.Context, req handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage) {
ctxSeen <- ctx
return handlers.ModelExecutionStream{
StatusCode: http.StatusOK,
Headers: http.Header{"X-Stream": []string{"ok"}},
Chunks: make(chan handlers.ModelExecutionChunk),
}, nil
},
})
callbackID, closeCallback := host.openCallbackContext(context.Background())
defer closeCallback()
rawReq, errMarshal := json.Marshal(rpcHostModelExecutionRequest{
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
EntryProtocol: "openai",
ExitProtocol: "openai",
Model: "model-1",
Stream: true,
Body: []byte(`{"stream":true}`),
},
HostCallbackID: callbackID,
})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelExecuteStream, rawReq)
if errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostModelStreamResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.StreamID == "" {
t.Fatalf("stream id is empty: %#v", resp)
}
var streamCtx context.Context
select {
case streamCtx = <-ctxSeen:
case <-time.After(time.Second):
t.Fatal("model executor was not called")
}
closeCallback()
select {
case <-streamCtx.Done():
case <-time.After(time.Second):
t.Fatal("stream context was not canceled after callback scope closed")
}
}
func TestHostModelStreamReadAfterCallbackCloseReturnsDone(t *testing.T) {
host := New()
chunks := make(chan handlers.ModelExecutionChunk)
host.SetModelExecutor(&fakeHostModelExecutor{
executeModelStream: func(ctx context.Context, req handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage) {
return handlers.ModelExecutionStream{
StatusCode: http.StatusOK,
Chunks: chunks,
}, nil
},
})
callbackID, closeCallback := host.openCallbackContext(context.Background())
rawReq, errMarshal := json.Marshal(rpcHostModelExecutionRequest{
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
EntryProtocol: "openai",
ExitProtocol: "openai",
Model: "model-1",
Stream: true,
Body: []byte(`{"stream":true}`),
},
HostCallbackID: callbackID,
})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelExecuteStream, rawReq)
if errCall != nil {
t.Fatalf("execute stream callback error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostModelStreamResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode stream response: %v", errDecode)
}
if resp.StreamID == "" {
t.Fatalf("stream id is empty: %#v", resp)
}
closeCallback()
readReq, errMarshal := json.Marshal(pluginapi.HostModelStreamReadRequest{StreamID: resp.StreamID})
if errMarshal != nil {
t.Fatalf("marshal read request: %v", errMarshal)
}
readDone := make(chan pluginapi.HostModelStreamReadResponse, 1)
readErr := make(chan error, 1)
go func() {
rawRead, errRead := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelStreamRead, readReq)
if errRead != nil {
readErr <- errRead
return
}
doneResp, errDecodeRead := decodeRPCEnvelope[pluginapi.HostModelStreamReadResponse](rawRead)
if errDecodeRead != nil {
readErr <- errDecodeRead
return
}
readDone <- doneResp
}()
select {
case errRead := <-readErr:
t.Fatalf("read after callback close error = %v", errRead)
case doneResp := <-readDone:
if !doneResp.Done || len(doneResp.Payload) != 0 || doneResp.Error != "" {
t.Fatalf("read after callback close = %#v, want done without payload/error", doneResp)
}
case <-time.After(time.Second):
t.Fatal("read after callback close blocked")
}
}
func TestHostModelExecuteStreamStartupErrorCleansUp(t *testing.T) {
host := New()
ctxSeen := make(chan context.Context, 1)
host.SetModelExecutor(&fakeHostModelExecutor{
executeModelStream: func(ctx context.Context, req handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage) {
ctxSeen <- ctx
return handlers.ModelExecutionStream{}, &interfaces.ErrorMessage{
StatusCode: http.StatusBadGateway,
}
},
})
rawReq, errMarshal := json.Marshal(pluginapi.HostModelExecutionRequest{
EntryProtocol: "openai",
ExitProtocol: "openai",
Model: "model-1",
Stream: true,
Body: []byte(`{"stream":true}`),
})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelExecuteStream, rawReq)
if errCall == nil {
t.Fatalf("execute stream callback error is nil, raw response = %q", rawResp)
}
if rawResp != nil {
t.Fatalf("raw response = %q, want nil on startup error", rawResp)
}
if !strings.Contains(errCall.Error(), "status 502") {
t.Fatalf("execute stream callback error = %v, want status 502", errCall)
}
var streamCtx context.Context
select {
case streamCtx = <-ctxSeen:
case <-time.After(time.Second):
t.Fatal("model executor was not called")
}
select {
case <-streamCtx.Done():
case <-time.After(time.Second):
t.Fatal("stream context was not canceled after startup error")
}
gotCount := hostModelStreamCountForTest(t, host)
if gotCount != 0 {
t.Fatalf("model stream count = %d, want 0", gotCount)
}
}
func TestHostModelCallbacksValidateStreamMode(t *testing.T) {
host := New()
rawExecuteReq, errMarshal := json.Marshal(pluginapi.HostModelExecutionRequest{
EntryProtocol: "openai",
ExitProtocol: "openai",
Model: "model-1",
Stream: true,
})
if errMarshal != nil {
t.Fatalf("marshal execute request: %v", errMarshal)
}
_, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelExecute, rawExecuteReq)
if errCall == nil || !strings.Contains(errCall.Error(), "host.model.execute requires stream=false") {
t.Fatalf("execute callback error = %v, want stream=false validation error", errCall)
}
rawStreamReq, errMarshal := json.Marshal(pluginapi.HostModelExecutionRequest{
EntryProtocol: "openai",
ExitProtocol: "openai",
Model: "model-1",
Stream: false,
})
if errMarshal != nil {
t.Fatalf("marshal execute stream request: %v", errMarshal)
}
_, errCall = host.callFromPlugin(context.Background(), pluginabi.MethodHostModelExecuteStream, rawStreamReq)
if errCall == nil || !strings.Contains(errCall.Error(), "host.model.execute_stream requires stream=true") {
t.Fatalf("execute stream callback error = %v, want stream=true validation error", errCall)
}
}
func TestHostModelCallbacksRequireExecutor(t *testing.T) {
host := New()
rawExecuteReq, errMarshal := json.Marshal(pluginapi.HostModelExecutionRequest{
EntryProtocol: "openai",
ExitProtocol: "openai",
Model: "model-1",
})
if errMarshal != nil {
t.Fatalf("marshal execute request: %v", errMarshal)
}
_, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelExecute, rawExecuteReq)
if errCall == nil || !strings.Contains(errCall.Error(), "host model executor is unavailable") {
t.Fatalf("execute callback error = %v, want unavailable executor error", errCall)
}
rawStreamReq, errMarshal := json.Marshal(pluginapi.HostModelExecutionRequest{
EntryProtocol: "openai",
ExitProtocol: "openai",
Model: "model-1",
Stream: true,
})
if errMarshal != nil {
t.Fatalf("marshal execute stream request: %v", errMarshal)
}
_, errCall = host.callFromPlugin(context.Background(), pluginabi.MethodHostModelExecuteStream, rawStreamReq)
if errCall == nil || !strings.Contains(errCall.Error(), "host model executor is unavailable") {
t.Fatalf("execute stream callback error = %v, want unavailable executor error", errCall)
}
}
func TestHostModelStreamReadAndCloseValidateStreamID(t *testing.T) {
host := New()
rawReadReq, errMarshal := json.Marshal(pluginapi.HostModelStreamReadRequest{})
if errMarshal != nil {
t.Fatalf("marshal read request: %v", errMarshal)
}
_, errRead := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelStreamRead, rawReadReq)
if errRead == nil || !strings.Contains(errRead.Error(), "model stream id is required") {
t.Fatalf("read callback error = %v, want required stream id error", errRead)
}
rawCloseReq, errMarshal := json.Marshal(pluginapi.HostModelStreamCloseRequest{})
if errMarshal != nil {
t.Fatalf("marshal close request: %v", errMarshal)
}
rawClose, errClose := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelStreamClose, rawCloseReq)
if errClose != nil {
t.Fatalf("close callback error = %v", errClose)
}
_, errDecode := decodeRPCEnvelope[rpcEmptyResponse](rawClose)
if errDecode != nil {
t.Fatalf("decode close response: %v", errDecode)
}
}
func TestHostModelStreamReadReturnsPayloadAndTerminalError(t *testing.T) {
host := New()
chunks := make(chan handlers.ModelExecutionChunk, 2)
chunks <- handlers.ModelExecutionChunk{Payload: []byte("first")}
chunks <- handlers.ModelExecutionChunk{Err: &handlers.ModelExecutionStreamError{
StatusCode: http.StatusBadGateway,
Message: "terminal boom",
}}
host.SetModelExecutor(&fakeHostModelExecutor{
executeModelStream: func(ctx context.Context, req handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage) {
return handlers.ModelExecutionStream{
StatusCode: http.StatusOK,
Headers: http.Header{"X-Stream": []string{"ok"}},
Chunks: chunks,
}, nil
},
})
streamID := openHostModelStreamForTest(t, host)
readReq, errMarshal := json.Marshal(pluginapi.HostModelStreamReadRequest{StreamID: streamID})
if errMarshal != nil {
t.Fatalf("marshal read request: %v", errMarshal)
}
rawRead, errRead := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelStreamRead, readReq)
if errRead != nil {
t.Fatalf("read callback error = %v", errRead)
}
first, errDecode := decodeRPCEnvelope[pluginapi.HostModelStreamReadResponse](rawRead)
if errDecode != nil {
t.Fatalf("decode read response: %v", errDecode)
}
if string(first.Payload) != "first" || first.Done || first.Error != "" {
t.Fatalf("first read = %#v, want payload without done", first)
}
rawRead, errRead = host.callFromPlugin(context.Background(), pluginabi.MethodHostModelStreamRead, readReq)
if errRead != nil {
t.Fatalf("terminal read callback error = %v", errRead)
}
terminal, errDecode := decodeRPCEnvelope[pluginapi.HostModelStreamReadResponse](rawRead)
if errDecode != nil {
t.Fatalf("decode terminal response: %v", errDecode)
}
if !terminal.Done || terminal.Error != "terminal boom" || len(terminal.Payload) != 0 {
t.Fatalf("terminal read = %#v, want done terminal error", terminal)
}
}
func TestHostModelStreamExplicitCloseCancelsStream(t *testing.T) {
host := New()
ctxSeen := make(chan context.Context, 1)
host.SetModelExecutor(&fakeHostModelExecutor{
executeModelStream: func(ctx context.Context, req handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage) {
ctxSeen <- ctx
return handlers.ModelExecutionStream{
StatusCode: http.StatusOK,
Chunks: make(chan handlers.ModelExecutionChunk),
}, nil
},
})
streamID := openHostModelStreamForTest(t, host)
var streamCtx context.Context
select {
case streamCtx = <-ctxSeen:
case <-time.After(time.Second):
t.Fatal("model executor was not called")
}
closeReq, errMarshal := json.Marshal(pluginapi.HostModelStreamCloseRequest{StreamID: streamID})
if errMarshal != nil {
t.Fatalf("marshal close request: %v", errMarshal)
}
if _, errClose := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelStreamClose, closeReq); errClose != nil {
t.Fatalf("close callback error = %v", errClose)
}
select {
case <-streamCtx.Done():
case <-time.After(time.Second):
t.Fatal("stream context was not canceled after explicit close")
}
if _, errClose := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelStreamClose, closeReq); errClose != nil {
t.Fatalf("second close callback error = %v", errClose)
}
}
func openHostModelStreamForTest(t *testing.T, host *Host) string {
t.Helper()
rawReq, errMarshal := json.Marshal(pluginapi.HostModelExecutionRequest{
EntryProtocol: "openai",
ExitProtocol: "openai",
Model: "model-1",
Stream: true,
Body: []byte(`{"stream":true}`),
})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelExecuteStream, rawReq)
if errCall != nil {
t.Fatalf("execute stream callback error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostModelStreamResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode stream response: %v", errDecode)
}
if resp.StreamID == "" {
t.Fatalf("stream id is empty: %#v", resp)
}
return resp.StreamID
}
func hostModelStreamCountForTest(t *testing.T, host *Host) int {
t.Helper()
host.modelStreams.mu.Lock()
defer host.modelStreams.mu.Unlock()
return len(host.modelStreams.streams)
}
func TestHostLogCallbackRestoresRegisteredRequestContext(t *testing.T) {
host := New()
ctx := logging.WithRequestID(context.Background(), "request-123")
callbackID, closeCallback := host.openCallbackContext(ctx)
defer closeCallback()
var out bytes.Buffer
logger := log.StandardLogger()
originalOut := logger.Out
originalFormatter := logger.Formatter
originalLevel := logger.Level
log.SetOutput(&out)
log.SetFormatter(&log.TextFormatter{
DisableColors: true,
DisableTimestamp: true,
})
log.SetLevel(log.InfoLevel)
defer func() {
log.SetOutput(originalOut)
log.SetFormatter(originalFormatter)
log.SetLevel(originalLevel)
}()
rawReq, errMarshal := json.Marshal(rpcHostLogRequest{
HostCallbackID: callbackID,
Level: "info",
Message: "plugin callback message",
})
if errMarshal != nil {
t.Fatalf("marshal log request: %v", errMarshal)
}
if _, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostLog, rawReq); errCall != nil {
t.Fatalf("log callback error = %v", errCall)
}
got := out.String()
if !strings.Contains(got, "plugin callback message") || !strings.Contains(got, "request_id=request-123") {
t.Fatalf("log output = %q, want message and request_id field", got)
}
}

View file

@ -0,0 +1,65 @@
//go:build cgo && (linux || darwin || freebsd)
package pluginhost
/*
#include <stdint.h>
#include <stdlib.h>
typedef struct {
void* ptr;
size_t len;
} cliproxy_buffer;
*/
import "C"
import (
"context"
"unsafe"
)
//export cliproxyHostCall
func cliproxyHostCall(hostCtx unsafe.Pointer, 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 hostCtx == nil || method == nil {
return 1
}
id := uintptr(*(*C.uintptr_t)(hostCtx))
rawHost, okHost := hostCallbackEntries.Load(id)
if !okHost {
return 1
}
entry, okHost := rawHost.(dynamicHostCallbackEntry)
if !okHost || entry.host == nil {
return 1
}
var requestBytes []byte
if request != nil && requestLen > 0 {
requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen))
}
ctx := withHostCallbackPluginID(context.Background(), entry.pluginID)
resp, errCall := entry.host.callFromPlugin(ctx, C.GoString(method), requestBytes)
if errCall != nil {
resp = marshalRPCError("host_call_failed", errCall.Error())
}
if len(resp) == 0 || response == nil {
return 0
}
ptr := C.CBytes(resp)
if ptr == nil {
return 1
}
response.ptr = ptr
response.len = C.size_t(len(resp))
return 0
}
//export cliproxyHostFree
func cliproxyHostFree(ptr unsafe.Pointer, len C.size_t) {
if ptr != nil {
C.free(ptr)
}
}

View file

@ -0,0 +1,87 @@
package pluginhost
import (
"context"
"encoding/json"
"fmt"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func (h *Host) callHostModelExecuteStream(ctx context.Context, request []byte) ([]byte, error) {
var req rpcHostModelExecutionRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host model execution stream request: %w", errUnmarshal)
}
if !req.Stream {
return nil, fmt.Errorf("host.model.execute_stream requires stream=true")
}
executor := h.currentModelExecutor()
if executor == nil {
return nil, fmt.Errorf("host model executor is unavailable")
}
skipPluginID := h.callbackCallerPluginID(ctx, req.HostCallbackID)
callbackCtx := h.resolveCallbackContext(req.HostCallbackID, ctx)
if callbackCtx == nil {
callbackCtx = context.Background()
}
// Detach request cancellation while preserving callback values; callback cleanup owns the model stream lifetime.
streamCtx, cancel := context.WithCancel(context.WithoutCancel(callbackCtx))
stream, errMsg := executor.ExecuteModelStream(streamCtx, modelExecutionRequestFromPlugin(req.HostModelExecutionRequest, skipPluginID))
if errMsg != nil {
cancel()
return nil, modelExecutionError(errMsg)
}
streamID := ""
if h.modelStreams != nil {
streamID = h.modelStreams.open(req.HostCallbackID, stream.Chunks, cancel)
}
if streamID == "" {
cancel()
return nil, fmt.Errorf("host model stream bridge is unavailable")
}
if req.HostCallbackID != "" {
h.addCallbackCleanup(req.HostCallbackID, func() {
h.modelStreams.close(streamID)
})
}
return marshalRPCResult(pluginapi.HostModelStreamResponse{
StatusCode: stream.StatusCode,
Headers: cloneHeader(stream.Headers),
StreamID: streamID,
})
}
func (h *Host) callHostModelStreamRead(ctx context.Context, request []byte) ([]byte, error) {
var req pluginapi.HostModelStreamReadRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host model stream read request: %w", errUnmarshal)
}
if h == nil || h.modelStreams == nil {
return nil, fmt.Errorf("host model stream bridge is unavailable")
}
chunk, done, errRead := h.modelStreams.read(ctx, req.StreamID)
if errRead != nil {
return nil, errRead
}
resp := pluginapi.HostModelStreamReadResponse{
Payload: append([]byte(nil), chunk.Payload...),
Done: done,
}
if chunk.Err != nil {
resp.Error = chunk.Err.Error()
resp.Done = true
}
return marshalRPCResult(resp)
}
func (h *Host) callHostModelStreamClose(request []byte) ([]byte, error) {
var req pluginapi.HostModelStreamCloseRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host model stream close request: %w", errUnmarshal)
}
if h != nil && h.modelStreams != nil {
h.modelStreams.close(req.StreamID)
}
return marshalRPCResult(rpcEmptyResponse{})
}

View file

@ -0,0 +1,76 @@
package pluginhost
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestHostModelExecuteStreamDetachesFromCallbackParentCancel(t *testing.T) {
host := New()
ctxSeen := make(chan context.Context, 1)
host.SetModelExecutor(&fakeHostModelExecutor{
executeModelStream: func(ctx context.Context, req handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage) {
ctxSeen <- ctx
return handlers.ModelExecutionStream{
StatusCode: http.StatusOK,
Chunks: make(chan handlers.ModelExecutionChunk),
}, nil
},
})
parentCtx, cancelParent := context.WithCancel(context.Background())
callbackID, closeCallback := host.openCallbackContext(parentCtx)
defer closeCallback()
rawReq, errMarshal := json.Marshal(rpcHostModelExecutionRequest{
HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
EntryProtocol: "openai",
ExitProtocol: "openai",
Model: "model-1",
Stream: true,
Body: []byte(`{"stream":true}`),
},
HostCallbackID: callbackID,
})
if errMarshal != nil {
t.Fatalf("marshal request: %v", errMarshal)
}
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostModelExecuteStream, rawReq)
if errCall != nil {
t.Fatalf("callFromPlugin() error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostModelStreamResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.StreamID == "" {
t.Fatalf("stream id is empty: %#v", resp)
}
var streamCtx context.Context
select {
case streamCtx = <-ctxSeen:
case <-time.After(time.Second):
t.Fatal("model executor was not called")
}
cancelParent()
select {
case <-streamCtx.Done():
t.Fatal("stream context was canceled by callback parent context")
default:
}
closeCallback()
select {
case <-streamCtx.Done():
case <-time.After(time.Second):
t.Fatal("stream context was not canceled after callback scope closed")
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,172 @@
package pluginhost
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
type hostHTTPClient struct {
host *Host
auth *coreauth.Auth
provider string
}
func (h *Host) newHTTPClient(auth *coreauth.Auth, providers ...string) pluginapi.HostHTTPClient {
provider := ""
if len(providers) > 0 {
provider = providers[0]
}
return &hostHTTPClient{host: h, auth: auth, provider: provider}
}
func (c *hostHTTPClient) Do(ctx context.Context, req pluginapi.HTTPRequest) (pluginapi.HTTPResponse, error) {
if ctx == nil {
ctx = context.Background()
}
resp, cfg, errDo := c.doHTTP(ctx, req)
if errDo != nil {
return pluginapi.HTTPResponse{}, errDo
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Warnf("pluginhost: response body close error: %v", errClose)
}
}()
helps.RecordAPIResponseMetadata(ctx, cfg, resp.StatusCode, resp.Header.Clone())
body, errReadAll := io.ReadAll(resp.Body)
if len(body) > 0 {
helps.AppendAPIResponseChunk(ctx, cfg, body)
}
if errReadAll != nil {
helps.RecordAPIResponseError(ctx, cfg, errReadAll)
return pluginapi.HTTPResponse{}, fmt.Errorf("read host http response: %w", errReadAll)
}
return pluginapi.HTTPResponse{
StatusCode: resp.StatusCode,
Headers: cloneHeader(resp.Header),
Body: body,
}, nil
}
func (c *hostHTTPClient) DoStream(ctx context.Context, req pluginapi.HTTPRequest) (pluginapi.HTTPStreamResponse, error) {
if ctx == nil {
ctx = context.Background()
}
resp, cfg, errDo := c.doHTTP(ctx, req)
if errDo != nil {
return pluginapi.HTTPStreamResponse{}, errDo
}
helps.RecordAPIResponseMetadata(ctx, cfg, resp.StatusCode, resp.Header.Clone())
chunks := make(chan pluginapi.HTTPStreamChunk)
go func() {
defer close(chunks)
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Warnf("pluginhost: stream response body close error: %v", errClose)
}
}()
buf := make([]byte, 32*1024)
for {
n, errRead := resp.Body.Read(buf)
if n > 0 {
payload := bytes.Clone(buf[:n])
helps.AppendAPIResponseChunk(ctx, cfg, payload)
select {
case <-ctx.Done():
return
case chunks <- pluginapi.HTTPStreamChunk{Payload: payload}:
}
}
if errRead != nil {
if errRead != io.EOF {
helps.RecordAPIResponseError(ctx, cfg, errRead)
select {
case <-ctx.Done():
case chunks <- pluginapi.HTTPStreamChunk{Err: errRead}:
}
}
return
}
}
}()
return pluginapi.HTTPStreamResponse{
StatusCode: resp.StatusCode,
Headers: cloneHeader(resp.Header),
Chunks: chunks,
}, nil
}
func (c *hostHTTPClient) doHTTP(ctx context.Context, req pluginapi.HTTPRequest) (*http.Response, *config.Config, error) {
if c == nil || c.host == nil {
return nil, nil, fmt.Errorf("host http client is unavailable")
}
if ctx == nil {
ctx = context.Background()
}
cfg := c.host.currentRuntimeConfig()
method := req.Method
if method == "" {
method = http.MethodGet
}
httpReq, errNewRequest := http.NewRequestWithContext(ctx, method, req.URL, bytes.NewReader(bytes.Clone(req.Body)))
if errNewRequest != nil {
return nil, cfg, fmt.Errorf("create host http request: %w", errNewRequest)
}
httpReq.Header = cloneHeader(req.Headers)
c.recordHTTPRequest(ctx, cfg, httpReq, req.Body)
client := helps.NewProxyAwareHTTPClient(ctx, cfg, c.auth, 0)
if client == nil {
client = &http.Client{}
}
resp, errDo := client.Do(httpReq)
if errDo != nil {
helps.RecordAPIResponseError(ctx, cfg, errDo)
return nil, cfg, fmt.Errorf("execute host http request: %w", errDo)
}
return resp, cfg, nil
}
func (c *hostHTTPClient) recordHTTPRequest(ctx context.Context, cfg *config.Config, req *http.Request, body []byte) {
if req == nil {
return
}
provider := c.provider
var authID, authLabel, authType, authValue string
if c.auth != nil {
authID = c.auth.ID
authLabel = c.auth.Label
authType, authValue = c.auth.AccountInfo()
if provider == "" {
provider = c.auth.Provider
}
}
helps.RecordAPIRequest(ctx, cfg, helps.UpstreamRequestLog{
URL: req.URL.String(),
Method: req.Method,
Headers: req.Header.Clone(),
Body: bytes.Clone(body),
Provider: provider,
AuthID: authID,
AuthLabel: authLabel,
AuthType: authType,
AuthValue: authValue,
})
}
func (h *Host) currentRuntimeConfig() *config.Config {
if h == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
return h.runtimeConfig
}

View file

@ -0,0 +1,83 @@
package pluginhost
import (
"context"
"fmt"
"strconv"
"sync"
"sync/atomic"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type hostHTTPStreamBridge struct {
next atomic.Uint64
mu sync.Mutex
streams map[string]hostHTTPStreamEntry
}
type hostHTTPStreamEntry struct {
chunks <-chan pluginapi.HTTPStreamChunk
cancel context.CancelFunc
}
func newHostHTTPStreamBridge() *hostHTTPStreamBridge {
return &hostHTTPStreamBridge{streams: make(map[string]hostHTTPStreamEntry)}
}
func (b *hostHTTPStreamBridge) open(chunks <-chan pluginapi.HTTPStreamChunk, cancel context.CancelFunc) string {
if b == nil || chunks == nil {
if cancel != nil {
cancel()
}
return ""
}
id := strconv.FormatUint(b.next.Add(1), 10)
b.mu.Lock()
b.streams[id] = hostHTTPStreamEntry{chunks: chunks, cancel: cancel}
b.mu.Unlock()
return id
}
func (b *hostHTTPStreamBridge) read(ctx context.Context, id string) (pluginapi.HTTPStreamChunk, bool, error) {
if b == nil || id == "" {
return pluginapi.HTTPStreamChunk{}, true, fmt.Errorf("http stream id is required")
}
b.mu.Lock()
entry := b.streams[id]
b.mu.Unlock()
if entry.chunks == nil {
return pluginapi.HTTPStreamChunk{}, true, fmt.Errorf("http stream %s is not open", id)
}
if ctx == nil {
ctx = context.Background()
}
select {
case <-ctx.Done():
b.close(id)
return pluginapi.HTTPStreamChunk{}, true, ctx.Err()
case chunk, ok := <-entry.chunks:
if !ok {
b.close(id)
return pluginapi.HTTPStreamChunk{}, true, nil
}
if chunk.Err != nil {
b.close(id)
return chunk, true, nil
}
return chunk, false, nil
}
}
func (b *hostHTTPStreamBridge) close(id string) {
if b == nil || id == "" {
return
}
b.mu.Lock()
entry := b.streams[id]
delete(b.streams, id)
b.mu.Unlock()
if entry.cancel != nil {
entry.cancel()
}
}

View file

@ -0,0 +1,232 @@
//go:build cgo && (linux || darwin || freebsd)
package pluginhost
/*
#cgo linux LDFLAGS: -ldl
#cgo freebsd LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdint.h>
#include <stdlib.h>
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)(const char*, const 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;
typedef int (*cliproxy_plugin_init_fn)(const cliproxy_host_api*, cliproxy_plugin_api*);
extern int cliproxyHostCall(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
extern void cliproxyHostFree(void*, size_t);
static void* cliproxy_dlopen(const char* path) {
return dlopen(path, RTLD_NOW | RTLD_LOCAL);
}
static void* cliproxy_dlsym(void* handle, const char* name) {
return dlsym(handle, name);
}
static const char* cliproxy_dlerror(void) {
return dlerror();
}
static int cliproxy_dlclose(void* handle) {
return dlclose(handle);
}
static int cliproxy_call_init(void* fn, const cliproxy_host_api* host, cliproxy_plugin_api* plugin) {
return ((cliproxy_plugin_init_fn)fn)(host, plugin);
}
static int cliproxy_call_plugin(cliproxy_plugin_call_fn fn, const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
return fn(method, request, request_len, response);
}
static void cliproxy_free_plugin_buffer(cliproxy_plugin_free_fn fn, void* ptr, size_t len) {
fn(ptr, len);
}
static void cliproxy_shutdown_plugin(cliproxy_plugin_shutdown_fn fn) {
fn();
}
static void cliproxy_set_host_api(cliproxy_host_api* api, uint32_t abi_version, void* host_ctx) {
api->abi_version = abi_version;
api->host_ctx = host_ctx;
api->call = cliproxyHostCall;
api->free_buffer = cliproxyHostFree;
}
*/
import "C"
import (
"context"
"fmt"
"sync"
"sync/atomic"
"unsafe"
)
var (
hostCallbackID atomic.Uintptr
hostCallbackEntries sync.Map
)
type dynamicLibraryLoader struct{}
type dynamicLibraryClient struct {
handle unsafe.Pointer
hostAPI *C.cliproxy_host_api
hostCtx unsafe.Pointer
api C.cliproxy_plugin_api
}
func defaultPluginLoader() pluginLoader {
return dynamicLibraryLoader{}
}
func (dynamicLibraryLoader) Open(file pluginFile, host *Host) (pluginClient, error) {
cPath := C.CString(file.Path)
defer C.free(unsafe.Pointer(cPath))
handle := C.cliproxy_dlopen(cPath)
if handle == nil {
return nil, fmt.Errorf("dlopen %s: %s", file.Path, dlerrorString())
}
cSymbol := C.CString("cliproxy_plugin_init")
initSymbol := C.cliproxy_dlsym(handle, cSymbol)
C.free(unsafe.Pointer(cSymbol))
if initSymbol == nil {
C.cliproxy_dlclose(handle)
return nil, fmt.Errorf("missing cliproxy_plugin_init: %s", dlerrorString())
}
hostAPI := (*C.cliproxy_host_api)(C.malloc(C.size_t(unsafe.Sizeof(C.cliproxy_host_api{}))))
if hostAPI == nil {
C.cliproxy_dlclose(handle)
return nil, fmt.Errorf("allocate host api")
}
hostCtx := C.malloc(C.size_t(unsafe.Sizeof(C.uintptr_t(0))))
if hostCtx == nil {
C.free(unsafe.Pointer(hostAPI))
C.cliproxy_dlclose(handle)
return nil, fmt.Errorf("allocate host context")
}
id := hostCallbackID.Add(1)
*(*C.uintptr_t)(hostCtx) = C.uintptr_t(id)
hostCallbackEntries.Store(id, dynamicHostCallbackEntry{host: host, pluginID: file.ID})
C.cliproxy_set_host_api(hostAPI, C.uint32_t(pluginHostABIVersion), hostCtx)
client := &dynamicLibraryClient{
handle: handle,
hostAPI: hostAPI,
hostCtx: hostCtx,
}
rc := C.cliproxy_call_init(initSymbol, hostAPI, &client.api)
if rc != 0 {
client.Shutdown()
return nil, fmt.Errorf("cliproxy_plugin_init returned %d", int(rc))
}
if uint32(client.api.abi_version) != pluginHostABIVersion {
client.Shutdown()
return nil, fmt.Errorf("plugin ABI version %d is not supported", uint32(client.api.abi_version))
}
if client.api.call == nil || client.api.free_buffer == nil {
client.Shutdown()
return nil, fmt.Errorf("plugin function table is incomplete")
}
return client, nil
}
func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request []byte) ([]byte, error) {
if c == nil || c.api.call == nil {
return nil, fmt.Errorf("plugin client is closed")
}
if ctx != nil {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}
cMethod := C.CString(method)
defer C.free(unsafe.Pointer(cMethod))
var cRequest unsafe.Pointer
if len(request) > 0 {
cRequest = C.CBytes(request)
defer C.free(cRequest)
}
var response C.cliproxy_buffer
rc := C.cliproxy_call_plugin(c.api.call, cMethod, (*C.uint8_t)(cRequest), C.size_t(len(request)), &response)
var out []byte
if response.ptr != nil && response.len > 0 {
out = C.GoBytes(response.ptr, C.int(response.len))
}
if response.ptr != nil {
C.cliproxy_free_plugin_buffer(c.api.free_buffer, response.ptr, response.len)
}
if rc != 0 {
if isPluginErrorEnvelope(out) {
return out, nil
}
return nil, fmt.Errorf("plugin call %s returned %d: %s", method, int(rc), string(out))
}
return out, nil
}
func (c *dynamicLibraryClient) Shutdown() {
if c == nil {
return
}
if c.api.shutdown != nil {
C.cliproxy_shutdown_plugin(c.api.shutdown)
c.api.shutdown = nil
}
if c.hostCtx != nil {
id := uintptr(*(*C.uintptr_t)(c.hostCtx))
hostCallbackEntries.Delete(id)
C.free(c.hostCtx)
c.hostCtx = nil
}
if c.hostAPI != nil {
C.free(unsafe.Pointer(c.hostAPI))
c.hostAPI = nil
}
if c.handle != nil {
C.cliproxy_dlclose(c.handle)
c.handle = nil
}
}
func dlerrorString() string {
errText := C.cliproxy_dlerror()
if errText == nil {
return ""
}
return C.GoString(errText)
}

View file

@ -0,0 +1,15 @@
//go:build !cgo && !windows
package pluginhost
import "fmt"
type unsupportedLoader struct{}
func (unsupportedLoader) Open(file pluginFile, host *Host) (pluginClient, error) {
return nil, fmt.Errorf("standard dynamic library plugin loading requires cgo on this platform: %s", file.Path)
}
func defaultPluginLoader() pluginLoader {
return unsupportedLoader{}
}

View file

@ -0,0 +1,405 @@
//go:build windows
package pluginhost
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
type windowsBuffer struct {
ptr uintptr
len uintptr
}
type windowsHostAPI struct {
abiVersion uint32
hostCtx uintptr
call uintptr
freeBuffer uintptr
}
type windowsPluginAPI struct {
abiVersion uint32
call uintptr
freeBuffer uintptr
shutdown uintptr
}
var (
windowsHostCallbackID atomic.Uintptr
windowsHostCallbackEntries sync.Map
windowsHostCallCallback = syscall.NewCallback(windowsHostCall)
windowsHostFreeCallback = syscall.NewCallback(windowsHostFree)
shadowPluginCleanupOnce sync.Once
)
const (
shadowPluginPrefix = "cliproxy-plugin-"
shadowPluginTempPrefix = ".cliproxy-plugin-"
shadowPluginProcessDirPrefix = "pid-"
shadowPluginDigestLength = 32
)
type dynamicLibraryLoader struct{}
type dynamicLibraryClient struct {
dll *syscall.DLL
tempPath string
hostAPI *windowsHostAPI
hostCtx *uintptr
api windowsPluginAPI
}
func defaultPluginLoader() pluginLoader {
return dynamicLibraryLoader{}
}
func (dynamicLibraryLoader) Open(file pluginFile, host *Host) (pluginClient, error) {
loadPath, errShadow := shadowCopyPlugin(file)
if errShadow != nil {
return nil, errShadow
}
dll, errLoad := syscall.LoadDLL(loadPath)
if errLoad != nil {
removeShadowPlugin(loadPath)
return nil, errLoad
}
proc, errProc := dll.FindProc("cliproxy_plugin_init")
if errProc != nil {
_ = dll.Release()
removeShadowPlugin(loadPath)
return nil, errProc
}
id := windowsHostCallbackID.Add(1)
hostCtx := new(uintptr)
*hostCtx = id
windowsHostCallbackEntries.Store(id, dynamicHostCallbackEntry{host: host, pluginID: file.ID})
client := &dynamicLibraryClient{
dll: dll,
tempPath: loadPath,
hostCtx: hostCtx,
hostAPI: &windowsHostAPI{
abiVersion: pluginHostABIVersion,
hostCtx: uintptr(unsafe.Pointer(hostCtx)),
call: windowsHostCallCallback,
freeBuffer: windowsHostFreeCallback,
},
}
rc, _, errCall := proc.Call(uintptr(unsafe.Pointer(client.hostAPI)), uintptr(unsafe.Pointer(&client.api)))
if rc != 0 {
client.closeAfterOpenFailure()
return nil, fmt.Errorf("cliproxy_plugin_init returned %d: %v", rc, errCall)
}
if client.api.abiVersion != pluginHostABIVersion {
client.closeAfterOpenFailure()
return nil, fmt.Errorf("plugin ABI version %d is not supported", client.api.abiVersion)
}
if client.api.call == 0 || client.api.freeBuffer == 0 {
client.closeAfterOpenFailure()
return nil, fmt.Errorf("plugin function table is incomplete")
}
return client, nil
}
func shadowCopyPlugin(file pluginFile) (string, error) {
dir, errDir := shadowPluginDir()
if errDir != nil {
return "", errDir
}
shadowPluginCleanupOnce.Do(func() {
removeStaleShadowPlugins(dir)
})
return shadowCopyPluginToDir(file, dir)
}
func shadowCopyPluginToDir(file pluginFile, dir string) (string, error) {
source := filepath.Clean(file.Path)
tmp, errTemp := os.CreateTemp(dir, shadowPluginTempPrefix+file.ID+"-*"+filepath.Ext(source))
if errTemp != nil {
return "", errTemp
}
tmpName := tmp.Name()
removeTemp := true
defer func() {
if removeTemp {
removeShadowPlugin(tmpName)
}
}()
in, errOpen := os.Open(source)
if errOpen != nil {
_ = tmp.Close()
return "", errOpen
}
defer func() {
_ = in.Close()
}()
hasher := sha256.New()
size, errCopy := io.Copy(io.MultiWriter(tmp, hasher), in)
if errCopy != nil {
_ = tmp.Close()
return "", errCopy
}
if errClose := tmp.Close(); errClose != nil {
return "", errClose
}
digest := hex.EncodeToString(hasher.Sum(nil))
target := shadowPluginPath(dir, file.ID, digest, filepath.Ext(source))
if shadowPluginMatches(target, size, digest) {
return target, nil
}
if errRemove := os.Remove(target); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) {
if shadowPluginMatches(target, size, digest) {
return target, nil
}
removeShadowPlugin(target)
return "", fmt.Errorf("remove stale shadow plugin: %w", errRemove)
}
if errRename := os.Rename(tmpName, target); errRename != nil {
if shadowPluginMatches(target, size, digest) {
return target, nil
}
return "", fmt.Errorf("move shadow plugin: %w", errRename)
}
removeTemp = false
return target, nil
}
func shadowPluginDir() (string, error) {
dir := filepath.Join(os.TempDir(), "cliproxy-pluginhost", shadowPluginProcessDirName(os.Getpid()))
if errMkdir := os.MkdirAll(dir, 0o700); errMkdir != nil {
return "", errMkdir
}
return dir, nil
}
func shadowPluginProcessDirName(pid int) string {
return fmt.Sprintf("%s%d", shadowPluginProcessDirPrefix, pid)
}
func removeShadowPlugin(path string) {
if path == "" {
return
}
if errRemove := os.Remove(path); errRemove == nil {
return
}
pathPtr, errPath := windows.UTF16PtrFromString(path)
if errPath != nil {
return
}
_ = windows.MoveFileEx(pathPtr, nil, windows.MOVEFILE_DELAY_UNTIL_REBOOT)
}
func removeStaleShadowPlugins(dir string) {
entries, errRead := os.ReadDir(dir)
if errRead != nil {
return
}
for _, entry := range entries {
if entry == nil || entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasPrefix(name, shadowPluginPrefix) || strings.HasPrefix(name, shadowPluginTempPrefix) {
removeShadowPlugin(filepath.Join(dir, name))
}
}
}
func shadowPluginPath(dir string, id string, digest string, extension string) string {
if len(digest) > shadowPluginDigestLength {
digest = digest[:shadowPluginDigestLength]
}
return filepath.Join(dir, shadowPluginPrefix+id+"-"+digest+extension)
}
func shadowPluginMatches(path string, size int64, digest string) bool {
info, errStat := os.Stat(path)
if errStat != nil {
return false
}
if !info.Mode().IsRegular() || info.Size() != size {
return false
}
file, errOpen := os.Open(path)
if errOpen != nil {
return false
}
defer func() {
_ = file.Close()
}()
hasher := sha256.New()
if _, errCopy := io.Copy(hasher, file); errCopy != nil {
return false
}
return hex.EncodeToString(hasher.Sum(nil)) == digest
}
func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request []byte) ([]byte, error) {
if c == nil || c.api.call == 0 {
return nil, fmt.Errorf("plugin client is closed")
}
if ctx != nil {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}
methodBytes, errMethod := syscall.BytePtrFromString(method)
if errMethod != nil {
return nil, errMethod
}
var requestPtr uintptr
if len(request) > 0 {
requestPtr = uintptr(unsafe.Pointer(&request[0]))
}
responseMem, errAlloc := windows.LocalAlloc(
windows.LMEM_FIXED|windows.LMEM_ZEROINIT,
uint32(unsafe.Sizeof(windowsBuffer{})),
)
if errAlloc != nil {
return nil, fmt.Errorf("allocate plugin response buffer: %w", errAlloc)
}
if responseMem == 0 {
return nil, fmt.Errorf("allocate plugin response buffer")
}
defer func() {
_, _ = windows.LocalFree(windows.Handle(responseMem))
}()
response := (*windowsBuffer)(unsafe.Pointer(responseMem))
rc, _, _ := syscall.SyscallN(
c.api.call,
uintptr(unsafe.Pointer(methodBytes)),
requestPtr,
uintptr(len(request)),
responseMem,
)
var out []byte
if response.ptr != 0 && response.len > 0 {
out = unsafe.Slice((*byte)(unsafe.Pointer(response.ptr)), response.len)
out = append([]byte(nil), out...)
}
if response.ptr != 0 {
_, _, _ = syscall.SyscallN(c.api.freeBuffer, response.ptr, response.len)
}
if rc != 0 {
if isPluginErrorEnvelope(out) {
return out, nil
}
return nil, fmt.Errorf("plugin call %s returned %d: %s", method, rc, string(out))
}
return out, nil
}
func (c *dynamicLibraryClient) Shutdown() {
// Windows Go DLLs are not safe to hot-unload from the host process.
// The plugin was loaded from a shadow copy, so keeping the module mapped
// does not block deleting or replacing the source artifact.
c.close(false)
}
func (c *dynamicLibraryClient) closeAfterOpenFailure() {
c.close(true)
}
func (c *dynamicLibraryClient) close(releaseDLL bool) {
if c == nil {
return
}
if c.api.shutdown != 0 {
_, _, _ = syscall.SyscallN(c.api.shutdown)
c.api.shutdown = 0
}
if c.hostCtx != nil {
windowsHostCallbackEntries.Delete(*c.hostCtx)
c.hostCtx = nil
}
if c.dll != nil {
if releaseDLL {
_ = c.dll.Release()
}
c.dll = nil
}
removeShadowPlugin(c.tempPath)
c.tempPath = ""
}
func windowsHostCall(hostCtx uintptr, methodPtr uintptr, requestPtr uintptr, requestLen uintptr, responsePtr uintptr) uintptr {
if responsePtr != 0 {
response := (*windowsBuffer)(unsafe.Pointer(responsePtr))
response.ptr = 0
response.len = 0
}
if hostCtx == 0 || methodPtr == 0 {
return 1
}
id := *(*uintptr)(unsafe.Pointer(hostCtx))
rawHost, okHost := windowsHostCallbackEntries.Load(id)
if !okHost {
return 1
}
entry, okHost := rawHost.(dynamicHostCallbackEntry)
if !okHost || entry.host == nil {
return 1
}
var request []byte
if requestPtr != 0 && requestLen > 0 {
request = unsafe.Slice((*byte)(unsafe.Pointer(requestPtr)), requestLen)
request = append([]byte(nil), request...)
}
ctx := withHostCallbackPluginID(context.Background(), entry.pluginID)
resp, errCall := entry.host.callFromPlugin(ctx, windowsString(methodPtr), request)
if errCall != nil {
resp = marshalRPCError("host_call_failed", errCall.Error())
}
if len(resp) == 0 || responsePtr == 0 {
return 0
}
mem, errAlloc := windows.LocalAlloc(windows.LMEM_FIXED, uint32(len(resp)))
if errAlloc != nil || mem == 0 {
return 1
}
copy(unsafe.Slice((*byte)(unsafe.Pointer(mem)), len(resp)), resp)
response := (*windowsBuffer)(unsafe.Pointer(responsePtr))
response.ptr = mem
response.len = uintptr(len(resp))
return 0
}
func windowsHostFree(ptr uintptr, len uintptr) uintptr {
if ptr != 0 {
_, _ = windows.LocalFree(windows.Handle(ptr))
}
return 0
}
func windowsString(ptr uintptr) string {
if ptr == 0 {
return ""
}
bytes := make([]byte, 0)
for offset := uintptr(0); ; offset++ {
b := *(*byte)(unsafe.Pointer(ptr + offset))
if b == 0 {
break
}
bytes = append(bytes, b)
}
return string(bytes)
}

View file

@ -0,0 +1,231 @@
//go:build windows
package pluginhost
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"unsafe"
"golang.org/x/sys/windows"
)
var testReentrantHostCallback uintptr
func TestDynamicLibraryClientCallSurvivesReentrantCallbackStackGrowth(t *testing.T) {
testReentrantHostCallback = syscall.NewCallback(testGrowHostCallbackStack)
client := newGuardedPluginClient(&dynamicLibraryClient{api: windowsPluginAPI{
call: syscall.NewCallback(testReentrantPluginCall),
freeBuffer: syscall.NewCallback(testReentrantPluginFree),
}})
t.Cleanup(client.Shutdown)
got, errCall := client.Call(context.Background(), "model.route", []byte(`{}`))
if errCall != nil {
t.Fatalf("Call() error = %v", errCall)
}
want := `{"ok":true,"result":{"Handled":true}}`
if string(got) != want {
t.Fatalf("Call() response = %q, want %q", got, want)
}
}
func testReentrantPluginCall(_, _, _, responsePtr uintptr) uintptr {
if testReentrantHostCallback == 0 || responsePtr == 0 {
return 1
}
_, _, _ = syscall.SyscallN(testReentrantHostCallback)
raw := []byte(`{"ok":true,"result":{"Handled":true}}`)
mem, errAlloc := windows.LocalAlloc(windows.LMEM_FIXED, uint32(len(raw)))
if errAlloc != nil || mem == 0 {
return 1
}
copy(unsafe.Slice((*byte)(unsafe.Pointer(mem)), len(raw)), raw)
response := (*windowsBuffer)(unsafe.Pointer(responsePtr))
response.ptr = mem
response.len = uintptr(len(raw))
return 0
}
func testReentrantPluginFree(ptr, _ uintptr) uintptr {
if ptr != 0 {
_, _ = windows.LocalFree(windows.Handle(ptr))
}
return 0
}
func testGrowHostCallbackStack() uintptr {
return uintptr(testGrowStack(64))
}
//go:noinline
func testGrowStack(depth int) int {
var padding [1024]byte
for index := range padding {
padding[index] = byte(index + depth)
}
if depth == 0 {
return int(padding[0])
}
return testGrowStack(depth-1) + int(padding[depth%len(padding)])
}
func TestShadowPluginDirIsProcessScoped(t *testing.T) {
dir, errDir := shadowPluginDir()
if errDir != nil {
t.Fatalf("shadowPluginDir() error = %v", errDir)
}
want := filepath.Join(os.TempDir(), "cliproxy-pluginhost", fmt.Sprintf("pid-%d", os.Getpid()))
if dir != want {
t.Fatalf("shadowPluginDir() = %q, want %q", dir, want)
}
}
func TestShadowCopyPluginReusesContentAddressedShadow(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(t.TempDir(), "alpha.dll")
content := []byte("plugin-v1")
if errWrite := os.WriteFile(source, content, 0o644); errWrite != nil {
t.Fatalf("WriteFile() error = %v", errWrite)
}
file := pluginFile{ID: "alpha", Path: source}
first, errFirst := shadowCopyPluginToDir(file, dir)
if errFirst != nil {
t.Fatalf("shadowCopyPluginToDir() first error = %v", errFirst)
}
second, errSecond := shadowCopyPluginToDir(file, dir)
if errSecond != nil {
t.Fatalf("shadowCopyPluginToDir() second error = %v", errSecond)
}
if second != first {
t.Fatalf("second shadow path = %q, want reused path %q", second, first)
}
gotContent, errRead := os.ReadFile(first)
if errRead != nil {
t.Fatalf("ReadFile(%s) error = %v", first, errRead)
}
if string(gotContent) != string(content) {
t.Fatalf("shadow content = %q, want %q", gotContent, content)
}
digest := sha256.Sum256(content)
wantDigest := hex.EncodeToString(digest[:])[:shadowPluginDigestLength]
name := filepath.Base(first)
if !strings.HasPrefix(name, shadowPluginPrefix+"alpha-") || !strings.Contains(name, wantDigest) {
t.Fatalf("shadow file name = %q, want alpha content digest %s", name, wantDigest)
}
if count := countShadowPluginFiles(t, dir); count != 1 {
t.Fatalf("shadow file count = %d, want 1", count)
}
}
func TestShadowCopyPluginCreatesNewPathForChangedContent(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(t.TempDir(), "alpha.dll")
file := pluginFile{ID: "alpha", Path: source}
if errWrite := os.WriteFile(source, []byte("plugin-v1"), 0o644); errWrite != nil {
t.Fatalf("WriteFile() v1 error = %v", errWrite)
}
first, errFirst := shadowCopyPluginToDir(file, dir)
if errFirst != nil {
t.Fatalf("shadowCopyPluginToDir() v1 error = %v", errFirst)
}
if errWrite := os.WriteFile(source, []byte("plugin-v2"), 0o644); errWrite != nil {
t.Fatalf("WriteFile() v2 error = %v", errWrite)
}
second, errSecond := shadowCopyPluginToDir(file, dir)
if errSecond != nil {
t.Fatalf("shadowCopyPluginToDir() v2 error = %v", errSecond)
}
if second == first {
t.Fatalf("second shadow path reused %q after content changed", second)
}
if count := countShadowPluginFiles(t, dir); count != 2 {
t.Fatalf("shadow file count = %d, want 2 versions", count)
}
}
func TestShadowCopyPluginReplacesCorruptSameSizeShadow(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(t.TempDir(), "alpha.dll")
content := []byte("plugin-v1")
if errWrite := os.WriteFile(source, content, 0o644); errWrite != nil {
t.Fatalf("WriteFile() source error = %v", errWrite)
}
digest := sha256.Sum256(content)
target := shadowPluginPath(dir, "alpha", hex.EncodeToString(digest[:]), ".dll")
if errWrite := os.WriteFile(target, []byte("corrupt!!"), 0o644); errWrite != nil {
t.Fatalf("WriteFile() corrupt shadow error = %v", errWrite)
}
gotPath, errCopy := shadowCopyPluginToDir(pluginFile{ID: "alpha", Path: source}, dir)
if errCopy != nil {
t.Fatalf("shadowCopyPluginToDir() error = %v", errCopy)
}
if gotPath != target {
t.Fatalf("shadow path = %q, want %q", gotPath, target)
}
gotContent, errRead := os.ReadFile(target)
if errRead != nil {
t.Fatalf("ReadFile(%s) error = %v", target, errRead)
}
if string(gotContent) != string(content) {
t.Fatalf("shadow content = %q, want %q", gotContent, content)
}
if count := countShadowPluginFiles(t, dir); count != 1 {
t.Fatalf("shadow file count = %d, want 1", count)
}
}
func TestRemoveStaleShadowPluginsOnlyRemovesShadowFiles(t *testing.T) {
dir := t.TempDir()
stale := filepath.Join(dir, shadowPluginPrefix+"alpha-deadbeef.dll")
temp := filepath.Join(dir, shadowPluginTempPrefix+"alpha-temp.dll")
keep := filepath.Join(dir, "keep.dll")
for _, path := range []string{stale, temp, keep} {
if errWrite := os.WriteFile(path, []byte("x"), 0o644); errWrite != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWrite)
}
}
removeStaleShadowPlugins(dir)
for _, path := range []string{stale, temp} {
if _, errStat := os.Stat(path); !os.IsNotExist(errStat) {
t.Fatalf("Stat(%s) error = %v, want not exist", path, errStat)
}
}
if _, errStat := os.Stat(keep); errStat != nil {
t.Fatalf("Stat(%s) error = %v, want kept", keep, errStat)
}
}
func countShadowPluginFiles(t *testing.T, dir string) int {
t.Helper()
entries, errRead := os.ReadDir(dir)
if errRead != nil {
t.Fatalf("ReadDir(%s) error = %v", dir, errRead)
}
count := 0
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), shadowPluginPrefix) {
count++
}
if strings.HasPrefix(entry.Name(), shadowPluginTempPrefix) {
t.Fatalf("temporary shadow file was not cleaned up: %s", entry.Name())
}
}
return count
}

View file

@ -0,0 +1,47 @@
package pluginhost
import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
func pluginLogFields(id, name, version, path string) log.Fields {
fields := log.Fields{
"plugin_id": strings.TrimSpace(id),
}
if name = strings.TrimSpace(name); name != "" {
fields["plugin_name"] = name
}
if version = strings.TrimSpace(version); version != "" {
fields["version"] = version
}
if path = strings.TrimSpace(path); path != "" {
fields["path"] = path
}
return fields
}
func pluginLogFieldsFromMetadata(id string, meta pluginapi.Metadata, path string) log.Fields {
return pluginLogFields(id, meta.Name, meta.Version, path)
}
func pluginHotReloadLogFields(id, activeVersion, activePath, retiredVersion, retiredPath string) log.Fields {
fields := log.Fields{
"plugin_id": strings.TrimSpace(id),
}
if activeVersion = strings.TrimSpace(activeVersion); activeVersion != "" {
fields["active_version"] = activeVersion
}
if activePath = strings.TrimSpace(activePath); activePath != "" {
fields["active_path"] = activePath
}
if retiredVersion = strings.TrimSpace(retiredVersion); retiredVersion != "" {
fields["retired_version"] = retiredVersion
}
if retiredPath = strings.TrimSpace(retiredPath); retiredPath != "" {
fields["retired_path"] = retiredPath
}
return fields
}

View file

@ -0,0 +1,56 @@
package pluginhost
import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestPluginLogFieldsIncludesNameVersionAndPath(t *testing.T) {
fields := pluginLogFieldsFromMetadata("sample", pluginapi.Metadata{
Name: "Sample Provider",
Version: "0.2.0",
}, "/tmp/plugins/sample-v0.2.0.dll")
if fields["plugin_id"] != "sample" {
t.Fatalf("plugin_id = %v, want sample", fields["plugin_id"])
}
if fields["plugin_name"] != "Sample Provider" {
t.Fatalf("plugin_name = %v, want Sample Provider", fields["plugin_name"])
}
if fields["version"] != "0.2.0" {
t.Fatalf("version = %v, want 0.2.0", fields["version"])
}
if fields["path"] != "/tmp/plugins/sample-v0.2.0.dll" {
t.Fatalf("path = %v, want /tmp/plugins/sample-v0.2.0.dll", fields["path"])
}
}
func TestPluginLogFieldsOmitsEmptyName(t *testing.T) {
fields := pluginLogFields("sample", "", "0.2.0", "")
if _, ok := fields["plugin_name"]; ok {
t.Fatalf("plugin_name = %v, want omitted", fields["plugin_name"])
}
}
func TestPluginHotReloadLogFieldsIncludesActiveAndRetiredIdentity(t *testing.T) {
fields := pluginHotReloadLogFields(
"sample",
"0.1.0",
"/tmp/plugins/sample-v0.1.0.dll",
"0.2.0",
"/tmp/plugins/sample-v0.2.0.dll",
)
for key, want := range map[string]string{
"plugin_id": "sample",
"active_version": "0.1.0",
"active_path": "/tmp/plugins/sample-v0.1.0.dll",
"retired_version": "0.2.0",
"retired_path": "/tmp/plugins/sample-v0.2.0.dll",
} {
if fields[key] != want {
t.Fatalf("%s = %v, want %s", key, fields[key], want)
}
}
}

View file

@ -0,0 +1,363 @@
package pluginhost
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/htmlsanitize"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
const (
managementBasePath = "/v0/management"
resourcePluginBasePath = "/v0/resource/plugins"
legacyPluginRoutePrefix = "/plugins"
)
type managementRouteRecord struct {
pluginID string
path string
version string
route pluginapi.ManagementRoute
}
type resourceRouteRecord struct {
pluginID string
path string
version string
route pluginapi.ResourceRoute
}
// RegisterManagementRoutes rebuilds the plugin-owned Management API and resource route tables.
func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string]struct{}) {
if h == nil {
return
}
nextRoutes := make(map[string]managementRouteRecord)
nextResources := make(map[string]resourceRouteRecord)
for _, record := range h.activeRecords() {
plugin := record.plugin.Capabilities.ManagementAPI
if plugin == nil || h.isPluginFused(record.id) {
continue
}
resp, errRegister := h.callManagementRegistrar(ctx, record, plugin)
if errRegister != nil {
log.Warnf("pluginhost: management registrar %s failed: %v", record.id, errRegister)
continue
}
for _, item := range resp.Routes {
method, path, okRoute := normalizeManagementRoute(item)
if !okRoute {
log.Warnf("pluginhost: plugin %s declared invalid management route %s %s", record.id, item.Method, item.Path)
continue
}
if routeDeclaresLegacyMenuResource(method, item) {
if !registerResourceRoute(nextResources, record, resourceRouteFromManagementRoute(item)) {
log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path)
}
continue
}
key := managementRouteKey(method, path)
if _, exists := reserved[key]; exists {
log.Warnf("pluginhost: plugin %s management route %s conflicts with an existing route and was skipped", record.id, key)
continue
}
if _, exists := nextRoutes[key]; exists {
log.Warnf("pluginhost: plugin %s management route %s conflicts with a higher-priority plugin and was skipped", record.id, key)
continue
}
item.Method = method
item.Path = path
nextRoutes[key] = managementRouteRecord{
pluginID: record.id,
path: record.path,
version: record.version,
route: item,
}
}
for _, item := range resp.Resources {
if !registerResourceRoute(nextResources, record, item) {
log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path)
}
}
}
h.mu.Lock()
h.managementRoutes = nextRoutes
h.resourceRoutes = nextResources
h.mu.Unlock()
}
func (h *Host) callManagementRegistrar(ctx context.Context, record capabilityRecord, plugin pluginapi.ManagementAPI) (resp pluginapi.ManagementRegistrationResponse, err error) {
if h == nil || plugin == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.ManagementRegistrationResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "ManagementAPI.RegisterManagement", recovered)
resp = pluginapi.ManagementRegistrationResponse{}
err = fmt.Errorf("management registrar panic: %v", recovered)
}
}()
return plugin.RegisterManagement(ctx, pluginapi.ManagementRegistrationRequest{
Plugin: record.meta,
BasePath: managementBasePath,
ResourceBasePath: resourcePluginBasePath + "/" + record.id,
})
}
func normalizeManagementRoute(item pluginapi.ManagementRoute) (string, string, bool) {
if item.Handler == nil {
return "", "", false
}
method := strings.ToUpper(strings.TrimSpace(item.Method))
if method == "" {
method = http.MethodGet
}
if strings.ContainsAny(method, " \t\r\n") {
return "", "", false
}
path := strings.TrimSpace(item.Path)
if path == "" {
return "", "", false
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if strings.HasPrefix(path, managementBasePath+"/") {
path = strings.TrimPrefix(path, managementBasePath)
}
path = strings.TrimRight(path, "/")
if path == "" {
return "", "", false
}
fullPath := managementBasePath + path
if !strings.HasPrefix(fullPath, managementBasePath+"/") {
return "", "", false
}
if strings.ContainsAny(fullPath, " \t\r\n") || strings.Contains(fullPath, ":") || strings.Contains(fullPath, "*") {
return "", "", false
}
return method, fullPath, true
}
func routeDeclaresLegacyMenuResource(method string, item pluginapi.ManagementRoute) bool {
return strings.EqualFold(strings.TrimSpace(method), http.MethodGet) && strings.TrimSpace(item.Menu) != ""
}
func resourceRouteFromManagementRoute(item pluginapi.ManagementRoute) pluginapi.ResourceRoute {
return pluginapi.ResourceRoute{
Path: item.Path,
Menu: item.Menu,
Description: item.Description,
Handler: item.Handler,
}
}
func registerResourceRoute(routes map[string]resourceRouteRecord, record capabilityRecord, item pluginapi.ResourceRoute) bool {
path, okRoute := normalizeResourceRoute(record.id, item)
if !okRoute {
return false
}
key := managementRouteKey(http.MethodGet, path)
if _, exists := routes[key]; exists {
log.Warnf("pluginhost: plugin %s resource route %s conflicts with a higher-priority plugin and was skipped", record.id, key)
return true
}
item.Path = path
routes[key] = resourceRouteRecord{
pluginID: record.id,
path: record.path,
version: record.version,
route: item,
}
return true
}
func normalizeResourceRoute(pluginID string, item pluginapi.ResourceRoute) (string, bool) {
if item.Handler == nil {
return "", false
}
pluginID = strings.TrimSpace(pluginID)
if pluginID == "" {
return "", false
}
path := strings.TrimSpace(item.Path)
if path == "" {
return "", false
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
pluginBasePath := resourcePluginBasePath + "/" + pluginID
if strings.HasPrefix(path, pluginBasePath+"/") {
path = strings.TrimPrefix(path, pluginBasePath)
} else if strings.HasPrefix(path, legacyPluginRoutePrefix+"/"+pluginID+"/") {
path = strings.TrimPrefix(path, legacyPluginRoutePrefix+"/"+pluginID)
}
path = strings.TrimRight(path, "/")
if path == "" {
return "", false
}
fullPath := pluginBasePath + path
if !strings.HasPrefix(fullPath, pluginBasePath+"/") {
return "", false
}
if strings.ContainsAny(fullPath, " \t\r\n") || strings.Contains(fullPath, ":") || strings.Contains(fullPath, "*") || strings.Contains(fullPath, "..") {
return "", false
}
return fullPath, true
}
func managementRouteKey(method, path string) string {
return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)
}
// ServeManagementHTTP dispatches an authenticated Management API request to a plugin route.
func (h *Host) ServeManagementHTTP(w http.ResponseWriter, r *http.Request) bool {
if h == nil || w == nil || r == nil || r.URL == nil {
return false
}
key := managementRouteKey(r.Method, r.URL.Path)
h.mu.Lock()
record, okRoute := h.managementRoutes[key]
h.mu.Unlock()
if !okRoute || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
return false
}
var body []byte
if r.Body != nil {
var errRead error
body, errRead = io.ReadAll(r.Body)
if errRead != nil {
http.Error(w, "failed to read plugin management request body", http.StatusBadRequest)
return true
}
if errClose := r.Body.Close(); errClose != nil {
log.Warnf("pluginhost: failed to close plugin management request body: %v", errClose)
}
}
r.Body = io.NopCloser(bytes.NewReader(body))
resp, errHandle := h.callManagementHandler(r.Context(), record, pluginapi.ManagementRequest{
Method: r.Method,
Path: r.URL.Path,
Headers: cloneHeader(r.Header),
Query: cloneValues(r.URL.Query()),
Body: bytes.Clone(body),
})
if errHandle != nil {
log.Warnf("pluginhost: management handler %s failed: %v", record.pluginID, errHandle)
http.Error(w, "plugin management handler failed", http.StatusBadGateway)
return true
}
resp.Body = escapeManagementResponseBody(resp)
for keyHeader, values := range resp.Headers {
for _, value := range values {
w.Header().Add(keyHeader, value)
}
}
statusCode := resp.StatusCode
if statusCode == 0 {
statusCode = http.StatusOK
}
w.WriteHeader(statusCode)
if _, errWrite := w.Write(resp.Body); errWrite != nil {
log.Warnf("pluginhost: failed to write plugin management response: %v", errWrite)
}
return true
}
// ServeResourceHTTP dispatches an unauthenticated browser-navigable resource request to a plugin route.
func (h *Host) ServeResourceHTTP(w http.ResponseWriter, r *http.Request) bool {
if h == nil || w == nil || r == nil || r.URL == nil {
return false
}
if !strings.EqualFold(r.Method, http.MethodGet) {
return false
}
key := managementRouteKey(http.MethodGet, r.URL.Path)
h.mu.Lock()
record, okRoute := h.resourceRoutes[key]
h.mu.Unlock()
if !okRoute || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
return false
}
resp, errHandle := h.callResourceHandler(r.Context(), record, pluginapi.ManagementRequest{
Method: http.MethodGet,
Path: r.URL.Path,
Headers: cloneHeader(r.Header),
Query: cloneValues(r.URL.Query()),
})
if errHandle != nil {
log.Warnf("pluginhost: resource handler %s failed: %v", record.pluginID, errHandle)
http.Error(w, "plugin resource handler failed", http.StatusBadGateway)
return true
}
for keyHeader, values := range resp.Headers {
for _, value := range values {
w.Header().Add(keyHeader, value)
}
}
statusCode := resp.StatusCode
if statusCode == 0 {
statusCode = http.StatusOK
}
w.WriteHeader(statusCode)
if _, errWrite := w.Write(resp.Body); errWrite != nil {
log.Warnf("pluginhost: failed to write plugin resource response: %v", errWrite)
}
return true
}
func (h *Host) callManagementHandler(ctx context.Context, record managementRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) {
if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) || !h.pluginIdentityCurrent(record.pluginID, record.path, record.version) {
return pluginapi.ManagementResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.pluginID, "ManagementHandler.HandleManagement", recovered)
resp = pluginapi.ManagementResponse{}
err = fmt.Errorf("management handler panic: %v", recovered)
}
}()
return record.route.Handler.HandleManagement(ctx, req)
}
func escapeManagementResponseBody(resp pluginapi.ManagementResponse) []byte {
body, okEscaped := htmlsanitize.JSONBodyIfLikely(resp.Body, resp.Headers.Get("Content-Type"))
if !okEscaped {
return resp.Body
}
return body
}
func (h *Host) callResourceHandler(ctx context.Context, record resourceRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) {
if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) || !h.pluginIdentityCurrent(record.pluginID, record.path, record.version) {
return pluginapi.ManagementResponse{}, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.pluginID, "ResourceHandler.HandleManagement", recovered)
resp = pluginapi.ManagementResponse{}
err = fmt.Errorf("resource handler panic: %v", recovered)
}
}()
return record.route.Handler.HandleManagement(ctx, req)
}

View file

@ -0,0 +1,276 @@
package pluginhost
import (
"context"
"encoding/json"
"html"
"net/http"
"net/http/httptest"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestRegisterManagementRoutesSkipsReservedAndUsesPriority(t *testing.T) {
high := &managementPluginDouble{
routes: []pluginapi.ManagementRoute{
{Method: http.MethodGet, Path: "/config", Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{Body: []byte("reserved")}, nil
})},
{Method: http.MethodGet, Path: "/plugins/shared/status", Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{Body: []byte("high")}, nil
})},
},
}
low := &managementPluginDouble{
routes: []pluginapi.ManagementRoute{
{Method: http.MethodGet, Path: "/plugins/shared/status", Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{Body: []byte("low")}, nil
})},
{Method: http.MethodPost, Path: "plugins/low/run", Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{StatusCode: http.StatusAccepted, Body: []byte("low-only")}, nil
})},
},
}
host := newHostWithRecords(
capabilityRecord{id: "low", priority: 1, plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ManagementAPI: low}}},
capabilityRecord{id: "high", priority: 10, plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ManagementAPI: high}}},
)
host.RegisterManagementRoutes(context.Background(), map[string]struct{}{
"GET /v0/management/config": {},
})
req := httptest.NewRequest(http.MethodGet, "/v0/management/plugins/shared/status", nil)
rec := httptest.NewRecorder()
if !host.ServeManagementHTTP(rec, req) {
t.Fatal("ServeManagementHTTP() = false, want true")
}
if rec.Body.String() != "high" {
t.Fatalf("Body = %q, want high", rec.Body.String())
}
req = httptest.NewRequest(http.MethodPost, "/v0/management/plugins/low/run", nil)
rec = httptest.NewRecorder()
if !host.ServeManagementHTTP(rec, req) {
t.Fatal("ServeManagementHTTP() for low route = false, want true")
}
if rec.Code != http.StatusAccepted || rec.Body.String() != "low-only" {
t.Fatalf("response = %d %q, want 202 low-only", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/v0/management/config", nil)
rec = httptest.NewRecorder()
if host.ServeManagementHTTP(rec, req) {
t.Fatal("reserved route was served by plugin")
}
}
func TestServeManagementHTMLEscapesJSONResponseStrings(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "json",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
ManagementAPI: &managementPluginDouble{routes: []pluginapi.ManagementRoute{{
Method: http.MethodGet,
Path: "/plugins/json/status",
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{
Headers: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
Body: []byte(`{
"title": "<script>alert(1)</script>",
"items": ["<b>first</b>", {"description": "safe & sound"}],
"count": 1
}`),
}, nil
}),
}}},
}},
})
host.RegisterManagementRoutes(context.Background(), nil)
req := httptest.NewRequest(http.MethodGet, "/v0/management/plugins/json/status", nil)
rec := httptest.NewRecorder()
if !host.ServeManagementHTTP(rec, req) {
t.Fatal("ServeManagementHTTP() = false, want true")
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var body map[string]any
if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String())
}
if body["title"] != html.EscapeString("<script>alert(1)</script>") {
t.Fatalf("title = %q, want escaped", body["title"])
}
items, okItems := body["items"].([]any)
if !okItems || len(items) != 2 {
t.Fatalf("items = %#v, want two items", body["items"])
}
if items[0] != html.EscapeString("<b>first</b>") {
t.Fatalf("items[0] = %q, want escaped", items[0])
}
nested, okNested := items[1].(map[string]any)
if !okNested {
t.Fatalf("items[1] = %#v, want object", items[1])
}
if nested["description"] != html.EscapeString("safe & sound") {
t.Fatalf("nested description = %q, want escaped", nested["description"])
}
if body["count"] != float64(1) {
t.Fatalf("count = %#v, want unchanged number", body["count"])
}
}
func TestManagementHandlerPanicFusesPlugin(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "panic",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
ManagementAPI: &managementPluginDouble{routes: []pluginapi.ManagementRoute{{
Method: http.MethodGet,
Path: "/plugins/panic",
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
panic("boom")
}),
}}},
}},
})
host.RegisterManagementRoutes(context.Background(), nil)
req := httptest.NewRequest(http.MethodGet, "/v0/management/plugins/panic", nil)
rec := httptest.NewRecorder()
if !host.ServeManagementHTTP(rec, req) {
t.Fatal("ServeManagementHTTP() = false, want true")
}
if rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", rec.Code)
}
if !host.isPluginFused("panic") {
t.Fatal("plugin was not fused after panic")
}
}
func TestServeResourceHTTPDispatchesPluginResource(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "resource",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
ManagementAPI: &managementPluginDouble{resources: []pluginapi.ResourceRoute{{
Path: "/status",
Menu: "Status",
Description: "Shows plugin status.",
Handler: managementHandlerFunc(func(_ context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
if req.Path != "/v0/resource/plugins/resource/status" {
t.Fatalf("resource request path = %q, want normalized resource path", req.Path)
}
return pluginapi.ManagementResponse{
Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
Body: []byte("<!doctype html><title>resource</title>"),
}, nil
}),
}}},
}},
})
host.RegisterManagementRoutes(context.Background(), nil)
req := httptest.NewRequest(http.MethodGet, "/v0/resource/plugins/resource/status", nil)
rec := httptest.NewRecorder()
if !host.ServeResourceHTTP(rec, req) {
t.Fatal("ServeResourceHTTP() = false, want true")
}
if rec.Code != http.StatusOK || rec.Body.String() != "<!doctype html><title>resource</title>" {
t.Fatalf("response = %d %q, want 200 html", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
t.Fatalf("Content-Type = %q, want text/html; charset=utf-8", got)
}
}
func TestLegacyGETManagementMenuRegistersAsResource(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "legacy",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
ManagementAPI: &managementPluginDouble{routes: []pluginapi.ManagementRoute{{
Method: http.MethodGet,
Path: "/plugins/legacy/status",
Menu: "Legacy Status",
Description: "Shows legacy plugin status.",
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{Body: []byte("legacy")}, nil
}),
}}},
}},
})
host.RegisterManagementRoutes(context.Background(), nil)
managementReq := httptest.NewRequest(http.MethodGet, "/v0/management/plugins/legacy/status", nil)
managementRec := httptest.NewRecorder()
if host.ServeManagementHTTP(managementRec, managementReq) {
t.Fatal("legacy menu route was served as Management API route")
}
resourceReq := httptest.NewRequest(http.MethodGet, "/v0/resource/plugins/legacy/status", nil)
resourceRec := httptest.NewRecorder()
if !host.ServeResourceHTTP(resourceRec, resourceReq) {
t.Fatal("legacy menu route was not served as resource route")
}
if resourceRec.Body.String() != "legacy" {
t.Fatalf("resource body = %q, want legacy", resourceRec.Body.String())
}
}
func TestRegisteredPluginsIncludesResourceMenus(t *testing.T) {
plugin := &managementPluginDouble{
routes: []pluginapi.ManagementRoute{
{
Method: http.MethodGet,
Path: "/plugins/menu/hidden",
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{}, nil
}),
},
},
resources: []pluginapi.ResourceRoute{
{
Path: "/status",
Menu: "Status",
Description: "Shows plugin status.",
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return pluginapi.ManagementResponse{}, nil
}),
},
},
}
host := newHostWithRecords(capabilityRecord{
id: "menu",
meta: pluginapi.Metadata{Name: "menu", Version: "1.0.0", Author: "test", GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI"},
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ManagementAPI: plugin}},
})
host.RegisterManagementRoutes(context.Background(), nil)
plugins := host.RegisteredPlugins()
if len(plugins) != 1 {
t.Fatalf("RegisteredPlugins() len = %d, want 1", len(plugins))
}
if len(plugins[0].Menus) != 1 {
t.Fatalf("RegisteredPlugins()[0].Menus = %#v, want one visible GET menu", plugins[0].Menus)
}
menu := plugins[0].Menus[0]
if menu.Path != "/v0/resource/plugins/menu/status" || menu.Menu != "Status" || menu.Description != "Shows plugin status." {
t.Fatalf("menu = %#v, want normalized status menu", menu)
}
}
type managementPluginDouble struct {
routes []pluginapi.ManagementRoute
resources []pluginapi.ResourceRoute
}
func (p *managementPluginDouble) RegisterManagement(context.Context, pluginapi.ManagementRegistrationRequest) (pluginapi.ManagementRegistrationResponse, error) {
return pluginapi.ManagementRegistrationResponse{Routes: p.routes, Resources: p.resources}, nil
}
type managementHandlerFunc func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error)
func (f managementHandlerFunc) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
return f(ctx, req)
}

View file

@ -0,0 +1,155 @@
package pluginhost
import (
"bytes"
"context"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
func (h *Host) RouteModel(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
return h.RouteModelExcept(ctx, req, "")
}
func (h *Host) HasModelRouters() bool {
return h.HasModelRoutersExcept("")
}
func (h *Host) HasModelRoutersExcept(skipPluginID string) bool {
if h == nil {
return false
}
skipPluginID = strings.TrimSpace(skipPluginID)
for _, record := range h.activeRecords() {
if record.plugin.Capabilities.ModelRouter != nil && !h.isPluginFused(record.id) && record.id != skipPluginID {
return true
}
}
return false
}
func (h *Host) RouteModelExcept(ctx context.Context, req pluginapi.ModelRouteRequest, skipPluginID string) (pluginapi.ModelRouteResponse, bool) {
if h == nil {
return pluginapi.ModelRouteResponse{}, false
}
skipPluginID = strings.TrimSpace(skipPluginID)
req.AvailableProviders = h.availableProvidersSnapshot()
for _, record := range h.activeRecords() {
router := record.plugin.Capabilities.ModelRouter
if router == nil || h.isPluginFused(record.id) || record.id == skipPluginID {
continue
}
nextReq := cloneModelRouteRequest(req)
nextReq.Plugin = clonePluginMetadata(record.meta)
nextReq.PluginID = record.id
resp, ok := h.callModelRouter(ctx, record.id, router, nextReq)
if !ok || !resp.Handled {
continue
}
resp, valid := normalizeModelRouteResponse(record.id, resp)
if !valid {
log.WithFields(log.Fields{"plugin_id": record.id, "target_kind": resp.TargetKind, "target": resp.Target}).Warn("pluginhost: model router returned invalid target")
continue
}
switch resp.TargetKind {
case pluginapi.ModelRouteTargetProvider:
if !h.HasBuiltinProvider(resp.Target) {
log.WithFields(log.Fields{"plugin_id": record.id, "target_provider": resp.Target}).Warn("pluginhost: model router returned unavailable provider")
continue
}
return resp, true
case pluginapi.ModelRouteTargetSelf, pluginapi.ModelRouteTargetExecutor:
if !h.executorPluginReady(resp.Target, nextReq) {
log.WithFields(log.Fields{"plugin_id": record.id, "target_plugin_id": resp.Target}).Warn("pluginhost: model router returned unavailable executor plugin")
continue
}
return resp, true
default:
log.WithFields(log.Fields{"plugin_id": record.id, "target_kind": resp.TargetKind}).Warn("pluginhost: model router returned unsupported target kind")
continue
}
}
return pluginapi.ModelRouteResponse{}, false
}
func (h *Host) callModelRouter(ctx context.Context, pluginID string, router pluginapi.ModelRouter, req pluginapi.ModelRouteRequest) (out pluginapi.ModelRouteResponse, ok bool) {
if h == nil || router == nil || h.isPluginFused(pluginID) {
return pluginapi.ModelRouteResponse{}, false
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(pluginID, "ModelRouter.RouteModel", recovered)
out = pluginapi.ModelRouteResponse{}
ok = false
}
}()
resp, errRoute := router.RouteModel(ctx, req)
if errRoute != nil {
log.WithField("plugin_id", pluginID).WithError(errRoute).Warn("pluginhost: model router failed")
return pluginapi.ModelRouteResponse{}, false
}
return resp, true
}
func normalizeModelRouteResponse(routerPluginID string, resp pluginapi.ModelRouteResponse) (pluginapi.ModelRouteResponse, bool) {
resp.TargetModel = strings.TrimSpace(resp.TargetModel)
switch resp.TargetKind {
case pluginapi.ModelRouteTargetSelf:
resp.Target = strings.TrimSpace(routerPluginID)
if resp.Target == "" {
return pluginapi.ModelRouteResponse{}, false
}
return resp, true
case pluginapi.ModelRouteTargetExecutor:
resp.Target = strings.TrimSpace(resp.Target)
if resp.Target == "" {
return pluginapi.ModelRouteResponse{}, false
}
return resp, true
case pluginapi.ModelRouteTargetProvider:
resp.Target = strings.ToLower(strings.TrimSpace(resp.Target))
if resp.Target == "" {
return pluginapi.ModelRouteResponse{}, false
}
return resp, true
default:
return pluginapi.ModelRouteResponse{}, false
}
}
func cloneModelRouteRequest(req pluginapi.ModelRouteRequest) pluginapi.ModelRouteRequest {
req.Headers = cloneHeader(req.Headers)
req.Query = cloneValues(req.Query)
req.Body = bytes.Clone(req.Body)
req.Metadata = cloneInterceptorMetadata(req.Metadata)
req.AvailableProviders = cloneStringSlice(req.AvailableProviders)
return req
}
// HasBuiltinProvider reports whether a built-in provider currently has at least one
// registered auth record.
func (h *Host) HasBuiltinProvider(provider string) bool {
if h == nil || h.authManager == nil {
return false
}
return h.authManager.HasProviderAuth(provider)
}
// BuiltinProviders returns built-in provider keys that currently have auth registered.
func (h *Host) BuiltinProviders() []string {
if h == nil || h.authManager == nil {
return nil
}
return h.authManager.AvailableProviders()
}
// availableProvidersSnapshot returns a defensive copy of BuiltinProviders for routing input.
func (h *Host) availableProvidersSnapshot() []string {
providers := h.BuiltinProviders()
if len(providers) == 0 {
return nil
}
return cloneStringSlice(providers)
}

View file

@ -0,0 +1,613 @@
package pluginhost
import (
"context"
"errors"
"fmt"
"testing"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func newRouteModelHostWithRecords(records ...capabilityRecord) *Host {
for i := range records {
caps := &records[i].plugin.Capabilities
if caps.Executor == nil {
continue
}
if len(caps.ExecutorInputFormats) == 0 {
caps.ExecutorInputFormats = []string{"openai"}
}
if len(caps.ExecutorOutputFormats) == 0 {
caps.ExecutorOutputFormats = []string{"openai"}
}
}
return newHostWithRecords(records...)
}
func TestHostRouteModelUsesHighestPriorityFirstMatch(t *testing.T) {
var lowCalled bool
host := newRouteModelHostWithRecords(
capabilityRecord{
id: "low",
priority: 1,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
lowCalled = true
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
capabilityRecord{
id: "high",
priority: 10,
meta: pluginapi.Metadata{Name: "High Router"},
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
if req.Plugin.Name != "High Router" {
t.Fatalf("Plugin metadata = %#v, want High Router", req.Plugin)
}
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf, Reason: "match"}, nil
}),
}},
},
)
resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"})
if !ok || !resp.Handled || resp.Target != "high" || resp.Reason != "match" {
t.Fatalf("RouteModel() = %#v, %v; want high executor handled", resp, ok)
}
if lowCalled {
t.Fatal("low priority router was called after high priority match")
}
}
func TestHostRouteModelContinuesAfterUnhandled(t *testing.T) {
var lowCalled bool
host := newRouteModelHostWithRecords(
capabilityRecord{
id: "low",
priority: 1,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
lowCalled = true
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
capabilityRecord{
id: "high",
priority: 10,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
return pluginapi.ModelRouteResponse{Handled: false}, nil
}),
}},
},
)
resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"})
if !lowCalled {
t.Fatal("low priority router was not called after unhandled high priority router")
}
if !ok || resp.Target != "low" {
t.Fatalf("RouteModel() = %#v, %v; want low executor handled", resp, ok)
}
}
func TestHostRouteModelAllowsExplicitExecutorPluginTarget(t *testing.T) {
host := newRouteModelHostWithRecords(
capabilityRecord{
id: "executor",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
}},
},
capabilityRecord{
id: "router",
priority: 10,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
if req.PluginID != "router" {
t.Fatalf("PluginID = %q, want router", req.PluginID)
}
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: "executor"}, nil
}),
}},
},
)
resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"})
if !ok || !resp.Handled || resp.Target != "executor" {
t.Fatalf("RouteModel() = %#v, %v; want executor target handled", resp, ok)
}
}
func TestHostExecutePluginExecutorByPluginIDPreservesModel(t *testing.T) {
var gotReq pluginapi.ExecutorRequest
executor := &fakeExecutor{
identifier: "plugin-provider",
execute: func(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) {
gotReq = req
return pluginapi.ExecutorResponse{Payload: []byte("plugin-ok")}, nil
},
}
host := newRouteModelHostWithRecords(capabilityRecord{
id: "executor",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: executor,
ExecutorInputFormats: []string{"openai"},
ExecutorOutputFormats: []string{"openai"},
}},
})
resp, errExecute := host.ExecutePluginExecutor(context.Background(), "executor", coreexecutor.Request{Model: "client-model", Payload: []byte(`{"model":"client-model"}`)}, coreexecutor.Options{OriginalRequest: []byte(`{"model":"client-model"}`)})
if errExecute != nil {
t.Fatalf("ExecutePluginExecutor() error = %v", errExecute)
}
if string(resp.Payload) != "plugin-ok" {
t.Fatalf("payload = %q, want plugin-ok", resp.Payload)
}
if gotReq.AuthID != "" || gotReq.AuthProvider != "" {
t.Fatalf("auth fields = %q/%q, want empty static executor auth", gotReq.AuthID, gotReq.AuthProvider)
}
if gotReq.Model != "client-model" {
t.Fatalf("executor request model = %q, want client-model", gotReq.Model)
}
}
func TestHostRouteModelDefaultsHandledRouterToOwnExecutor(t *testing.T) {
host := newRouteModelHostWithRecords(capabilityRecord{
id: "router",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
})
resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"})
if !ok || resp.Target != "router" {
t.Fatalf("RouteModel() = %#v, %v; want router executor handled", resp, ok)
}
}
func TestHostRouteModelSkipsUnavailableExecutorTargets(t *testing.T) {
calls := 0
host := newRouteModelHostWithRecords(
capabilityRecord{
id: "fallback",
priority: 1,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
calls++
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
capabilityRecord{
id: "missing-target",
priority: 20,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
calls++
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: "missing"}, nil
}),
}},
},
capabilityRecord{
id: "no-executor",
priority: 10,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
calls++
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
)
resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"})
if calls != 3 {
t.Fatalf("router calls = %d, want all routers tried", calls)
}
if !ok || resp.Target != "fallback" {
t.Fatalf("RouteModel() = %#v, %v; want fallback executor handled", resp, ok)
}
}
func TestHostRouteModelErrorAndPanicDoNotBreakFallback(t *testing.T) {
host := newRouteModelHostWithRecords(
capabilityRecord{
id: "fallback",
priority: 1,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
capabilityRecord{
id: "panic",
priority: 20,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
panic("router panic")
}),
}},
},
capabilityRecord{
id: "error",
priority: 10,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
return pluginapi.ModelRouteResponse{}, errors.New("temporary route failure")
}),
}},
},
)
resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"})
if !ok || resp.Target != "fallback" {
t.Fatalf("RouteModel() = %#v, %v; want fallback executor handled", resp, ok)
}
if !host.isPluginFused("panic") {
t.Fatal("panic router was not fused")
}
}
func TestHostHasModelRoutersReportsAvailableRouters(t *testing.T) {
host := newRouteModelHostWithRecords(
capabilityRecord{
id: "router",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
return pluginapi.ModelRouteResponse{}, nil
}),
}},
},
capabilityRecord{id: "other"},
)
if !host.HasModelRouters() {
t.Fatal("HasModelRouters() = false, want true")
}
if host.HasModelRoutersExcept("router") {
t.Fatal("HasModelRoutersExcept(router) = true, want false")
}
}
func TestHostRouteModelClonesPluginMetadata(t *testing.T) {
host := newRouteModelHostWithRecords(capabilityRecord{
id: "router",
meta: pluginapi.Metadata{
Name: "Router",
ConfigFields: []pluginapi.ConfigField{{
Name: "mode",
EnumValues: []string{"safe", "fast"},
}},
},
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
req.Plugin.ConfigFields[0].Name = "mutated"
req.Plugin.ConfigFields[0].EnumValues[0] = "mutated"
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
})
resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original"})
if !ok || resp.Target != "router" {
t.Fatalf("RouteModel() = %#v, %v; want router executor handled", resp, ok)
}
meta := host.Snapshot().records[0].meta
if meta.ConfigFields[0].Name != "mode" || meta.ConfigFields[0].EnumValues[0] != "safe" {
t.Fatalf("snapshot metadata was mutated: %#v", meta.ConfigFields[0])
}
}
func TestHostRouteModelSkipsOriginatingPlugin(t *testing.T) {
var originCalled bool
host := newRouteModelHostWithRecords(
capabilityRecord{
id: "origin",
priority: 10,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
originCalled = true
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
capabilityRecord{
id: "other",
priority: 1,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
)
resp, ok := host.RouteModelExcept(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"}, "origin")
if originCalled {
t.Fatal("origin router was called despite skip")
}
if !ok || resp.Target != "other" {
t.Fatalf("RouteModelExcept() = %#v, %v; want other executor handled", resp, ok)
}
}
// newHostWithAuthProviders builds a host whose AuthManager registers auths for the given
// provider keys, so built-in provider routing can be exercised.
func newHostWithAuthProviders(t *testing.T, providers []string, records ...capabilityRecord) *Host {
t.Helper()
host := newRouteModelHostWithRecords(records...)
manager := coreauth.NewManager(nil, nil, nil)
for i, provider := range providers {
auth := &coreauth.Auth{ID: fmt.Sprintf("auth-%s-%d", provider, i), Provider: provider}
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
t.Fatalf("Register(%s) error = %v", provider, errRegister)
}
}
host.authManager = manager
return host
}
func TestHostRouteModelRoutesToBuiltinProvider(t *testing.T) {
host := newHostWithAuthProviders(t, []string{"claude"}, capabilityRecord{
id: "router",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetProvider, Target: "claude", TargetModel: "claude-sonnet-4"}, nil
}),
}},
})
resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"})
if !ok || !resp.Handled || resp.Target != "claude" {
t.Fatalf("RouteModel() = %#v, %v; want claude provider handled", resp, ok)
}
if resp.TargetKind != pluginapi.ModelRouteTargetProvider {
t.Fatalf("TargetKind = %q, want provider", resp.TargetKind)
}
if resp.TargetModel != "claude-sonnet-4" {
t.Fatalf("TargetModel = %q, want claude-sonnet-4", resp.TargetModel)
}
}
func TestHostRouteModelSkipsUnavailableBuiltinProvider(t *testing.T) {
var fallbackCalled bool
host := newHostWithAuthProviders(t, []string{"claude"},
capabilityRecord{
id: "fallback",
priority: 1,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
fallbackCalled = true
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
capabilityRecord{
id: "missing-provider",
priority: 10,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetProvider, Target: "unknown-provider"}, nil
}),
}},
},
)
resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"})
if !fallbackCalled {
t.Fatal("fallback router was not called after unavailable provider target")
}
if !ok || resp.Target != "fallback" {
t.Fatalf("RouteModel() = %#v, %v; want fallback executor handled", resp, ok)
}
}
func TestHostRouteModelRejectsProviderAndExecutorBothSet(t *testing.T) {
var fallbackCalled bool
host := newHostWithAuthProviders(t, []string{"claude"},
capabilityRecord{
id: "fallback",
priority: 1,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
fallbackCalled = true
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
capabilityRecord{
id: "both",
priority: 10,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetKind("both"), Target: "claude"}, nil
}),
}},
},
)
resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"})
if !fallbackCalled {
t.Fatal("fallback router was not called after mutually exclusive targets")
}
if !ok || resp.Target != "fallback" {
t.Fatalf("RouteModel() = %#v, %v; want fallback executor handled", resp, ok)
}
}
func TestHostRouteModelPropagatesAvailableProviders(t *testing.T) {
var gotProviders []string
host := newHostWithAuthProviders(t, []string{"claude", "gemini"}, capabilityRecord{
id: "router",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fake-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
gotProviders = append([]string(nil), req.AvailableProviders...)
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
})
if _, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original"}); !ok {
t.Fatal("RouteModel() not handled")
}
want := []string{"claude", "gemini"}
if fmt.Sprint(gotProviders) != fmt.Sprint(want) {
t.Fatalf("AvailableProviders = %v, want %v", gotProviders, want)
}
}
func TestHostBuiltinProviderLookup(t *testing.T) {
host := newHostWithAuthProviders(t, []string{"Claude", "codex"})
if !host.HasBuiltinProvider("claude") {
t.Fatal("HasBuiltinProvider(claude) = false, want true")
}
if host.HasBuiltinProvider("missing") {
t.Fatal("HasBuiltinProvider(missing) = true, want false")
}
providers := host.BuiltinProviders()
if fmt.Sprint(providers) != fmt.Sprint([]string{"claude", "codex"}) {
t.Fatalf("BuiltinProviders() = %v, want [claude codex]", providers)
}
}
func TestHostRouteModelSkipsExecutorWithoutProviderIdentifier(t *testing.T) {
var fallbackCalled bool
host := newRouteModelHostWithRecords(
capabilityRecord{
id: "fallback",
priority: 1,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fallback-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
fallbackCalled = true
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
capabilityRecord{
id: "no-provider",
priority: 10,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
// Executor is declared but resolves no provider identifier, so execution
// would fail. Routing must skip it and fall through to the lower-priority router.
Executor: &fakeExecutor{identifierFunc: func() string { return "" }},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
)
resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model"})
if !fallbackCalled {
t.Fatal("fallback router was not called after executor without provider identifier was skipped")
}
if !ok || resp.Target != "fallback" {
t.Fatalf("RouteModel() = %#v, %v; want fallback executor handled", resp, ok)
}
}
func TestHostRouteModelSkipsExecutorWithUnsupportedFormats(t *testing.T) {
var fallbackCalled bool
host := newHostWithRecords(
capabilityRecord{
id: "fallback",
priority: 1,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fallback-provider"},
ExecutorInputFormats: []string{"openai"},
ExecutorOutputFormats: []string{"openai"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
fallbackCalled = true
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
capabilityRecord{
id: "unsupported-formats",
priority: 10,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "unsupported-provider"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
)
resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model", SourceFormat: "openai"})
if !fallbackCalled {
t.Fatal("fallback router was not called after executor with unsupported formats was skipped")
}
if !ok || resp.Target != "fallback" {
t.Fatalf("RouteModel() = %#v, %v; want fallback executor handled", resp, ok)
}
}
func TestHostRouteModelSkipsOAuthOnlyExecutorTargets(t *testing.T) {
var fallbackCalled bool
host := newHostWithRecords(
capabilityRecord{
id: "fallback",
priority: 1,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "fallback-provider"},
ExecutorModelScope: pluginapi.ExecutorModelScopeStatic,
ExecutorInputFormats: []string{"openai"},
ExecutorOutputFormats: []string{"openai"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
fallbackCalled = true
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
capabilityRecord{
id: "oauth-only",
priority: 10,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
Executor: &fakeExecutor{identifier: "oauth-provider"},
ExecutorModelScope: pluginapi.ExecutorModelScopeOAuth,
ExecutorInputFormats: []string{"openai"},
ExecutorOutputFormats: []string{"openai"},
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
},
)
resp, ok := host.RouteModel(context.Background(), pluginapi.ModelRouteRequest{RequestedModel: "original-model", SourceFormat: "openai"})
if !fallbackCalled {
t.Fatal("fallback router was not called after OAuth-only executor target was skipped")
}
if !ok || resp.Target != "fallback" {
t.Fatalf("RouteModel() = %#v, %v; want fallback executor handled", resp, ok)
}
}

View file

@ -0,0 +1,91 @@
package pluginhost
import (
"context"
"fmt"
"strconv"
"sync"
"sync/atomic"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
)
type modelStreamBridge struct {
next atomic.Uint64
mu sync.Mutex
streams map[string]modelStreamEntry
}
type modelStreamEntry struct {
ownerCallbackID string
chunks <-chan handlers.ModelExecutionChunk
cancel context.CancelFunc
}
func newModelStreamBridge() *modelStreamBridge {
return &modelStreamBridge{streams: make(map[string]modelStreamEntry)}
}
func (b *modelStreamBridge) open(ownerCallbackID string, chunks <-chan handlers.ModelExecutionChunk, cancel context.CancelFunc) string {
if b == nil || chunks == nil {
if cancel != nil {
cancel()
}
return ""
}
id := strconv.FormatUint(b.next.Add(1), 10)
b.mu.Lock()
b.streams[id] = modelStreamEntry{
ownerCallbackID: ownerCallbackID,
chunks: chunks,
cancel: cancel,
}
b.mu.Unlock()
return id
}
func (b *modelStreamBridge) read(ctx context.Context, id string) (handlers.ModelExecutionChunk, bool, error) {
if b == nil {
return handlers.ModelExecutionChunk{}, true, fmt.Errorf("model stream bridge is unavailable")
}
if id == "" {
return handlers.ModelExecutionChunk{}, true, fmt.Errorf("model stream id is required")
}
b.mu.Lock()
entry, ok := b.streams[id]
b.mu.Unlock()
if !ok || entry.chunks == nil {
return handlers.ModelExecutionChunk{}, true, nil
}
if ctx == nil {
ctx = context.Background()
}
select {
case <-ctx.Done():
b.close(id)
return handlers.ModelExecutionChunk{}, true, ctx.Err()
case chunk, okRead := <-entry.chunks:
if !okRead {
b.close(id)
return handlers.ModelExecutionChunk{}, true, nil
}
if chunk.Err != nil {
b.close(id)
return chunk, true, nil
}
return chunk, false, nil
}
}
func (b *modelStreamBridge) close(id string) {
if b == nil || id == "" {
return
}
b.mu.Lock()
entry := b.streams[id]
delete(b.streams, id)
b.mu.Unlock()
if entry.cancel != nil {
entry.cancel()
}
}

View file

@ -0,0 +1,313 @@
package pluginhost
import (
"errors"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
)
var (
pluginIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
pluginVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z.+-]*$`)
)
type pluginFile struct {
ID string
Path string
Version string
}
// PluginFileInfo describes a plugin binary selected by the host discovery rules.
type PluginFileInfo struct {
ID string
Path string
Version string
}
// ValidatePluginID reports whether id can be used as a plugin configuration key.
func ValidatePluginID(id string) bool {
return validPluginID(id)
}
func validPluginID(id string) bool {
return pluginIDPattern.MatchString(id)
}
func validPluginVersion(version string) bool {
return version != "" && !strings.HasPrefix(version, "v") && pluginVersionPattern.MatchString(version)
}
func pluginIDFromPath(path string) string {
file, ok := pluginFileFromPath(path, "")
if ok {
return file.ID
}
base := filepath.Base(path)
lowerBase := strings.ToLower(base)
for _, extension := range []string{".so", ".dylib", ".dll"} {
if strings.HasSuffix(lowerBase, extension) {
return base[:len(base)-len(extension)]
}
}
return base
}
func pluginFileFromPath(filePath string, requiredExtension string) (pluginFile, bool) {
base := filepath.Base(filePath)
lowerBase := strings.ToLower(base)
extension := strings.TrimSpace(requiredExtension)
if extension != "" {
if !strings.HasSuffix(lowerBase, strings.ToLower(extension)) {
return pluginFile{}, false
}
} else {
for _, candidateExtension := range []string{".so", ".dylib", ".dll"} {
if strings.HasSuffix(lowerBase, candidateExtension) {
extension = candidateExtension
break
}
}
if extension == "" {
return pluginFile{}, false
}
}
name := base[:len(base)-len(extension)]
id := name
version := ""
if versionIndex := strings.LastIndex(name, "-v"); versionIndex > 0 {
candidateID := name[:versionIndex]
candidateVersion := name[versionIndex+2:]
if validPluginID(candidateID) && validPluginVersion(candidateVersion) {
id = candidateID
version = candidateVersion
}
}
if !validPluginID(id) {
return pluginFile{}, false
}
return pluginFile{ID: id, Path: filePath, Version: version}, true
}
// PluginExtension returns the dynamic library file extension used for goos.
func PluginExtension(goos string) string {
return pluginExtension(goos)
}
func pluginExtension(goos string) string {
switch goos {
case "darwin":
return ".dylib"
case "windows":
return ".dll"
default:
return ".so"
}
}
func selectPluginFiles(root string, desiredVersions ...map[string]string) ([]pluginFile, error) {
selected, _, errSelect := selectPluginFilesWithCandidates(root, desiredVersions...)
return selected, errSelect
}
func selectPluginFilesWithCandidates(root string, desiredVersions ...map[string]string) ([]pluginFile, []pluginFile, error) {
root = strings.TrimSpace(root)
if root == "" {
root = "plugins"
}
desired := normalizeDesiredPluginVersions(desiredVersions...)
candidates := candidateDirs(root, runtime.GOOS, runtime.GOARCH)
extension := pluginExtension(runtime.GOOS)
selectedByID := make(map[string]pluginFile)
order := make([]string, 0)
all := make([]pluginFile, 0)
for _, dir := range candidates {
entries, errReadDir := os.ReadDir(dir)
if errReadDir != nil {
if os.IsNotExist(errReadDir) {
continue
}
return nil, nil, errReadDir
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if entry == nil || !entry.Type().IsRegular() {
continue
}
if strings.HasSuffix(strings.ToLower(entry.Name()), extension) {
files = append(files, filepath.Join(dir, entry.Name()))
}
}
sort.Strings(files)
for _, path := range files {
file, okFile := pluginFileFromPath(path, extension)
if !okFile {
continue
}
all = append(all, file)
current, exists := selectedByID[file.ID]
if !exists {
selectedByID[file.ID] = file
order = append(order, file.ID)
continue
}
if pluginFilePreferredForDesired(file, current, desired[file.ID]) {
selectedByID[file.ID] = file
}
}
}
selected := make([]pluginFile, 0, len(order))
for _, id := range order {
file := selectedByID[id]
if desiredVersion := desired[id]; desiredVersion != "" && file.Version != desiredVersion {
continue
}
selected = append(selected, file)
}
return selected, all, nil
}
func normalizeDesiredPluginVersions(sources ...map[string]string) map[string]string {
out := make(map[string]string)
for _, source := range sources {
for id, version := range source {
id = strings.TrimSpace(id)
version = normalizePluginDesiredVersion(version)
if id == "" || version == "" {
continue
}
out[id] = version
}
}
return out
}
func pluginFilePreferredForDesired(candidate pluginFile, current pluginFile, desiredVersion string) bool {
desiredVersion = normalizePluginDesiredVersion(desiredVersion)
if desiredVersion != "" {
candidateMatches := candidate.Version == desiredVersion
currentMatches := current.Version == desiredVersion
if candidateMatches != currentMatches {
return candidateMatches
}
}
return pluginFilePreferred(candidate, current)
}
func pluginFilePreferred(candidate pluginFile, current pluginFile) bool {
if candidate.Version == "" {
return false
}
if current.Version == "" {
return true
}
comparison, comparable := comparePluginVersions(candidate.Version, current.Version)
if !comparable {
return candidate.Version > current.Version
}
return comparison > 0
}
func comparePluginVersions(a, b string) (int, bool) {
segmentsA := strings.Split(a, ".")
segmentsB := strings.Split(b, ".")
length := len(segmentsA)
if len(segmentsB) > length {
length = len(segmentsB)
}
for index := 0; index < length; index++ {
numberA, okA := pluginVersionSegment(segmentsA, index)
numberB, okB := pluginVersionSegment(segmentsB, index)
if !okA || !okB {
return 0, false
}
if numberA != numberB {
if numberA < numberB {
return -1, true
}
return 1, true
}
}
return 0, true
}
func pluginVersionSegment(segments []string, index int) (int64, bool) {
if index >= len(segments) {
return 0, true
}
number, errParse := strconv.ParseInt(segments[index], 10, 64)
if errParse != nil || number < 0 {
return 0, false
}
return number, true
}
func cleanupUnselectedPluginFiles(root string, loaded []pluginFile) error {
if len(loaded) == 0 {
return nil
}
_, candidates, errSelect := selectPluginFilesWithCandidates(root)
if errSelect != nil {
return errSelect
}
loadedByID := make(map[string]map[string]struct{}, len(loaded))
for _, file := range loaded {
if strings.TrimSpace(file.ID) == "" || strings.TrimSpace(file.Path) == "" {
continue
}
paths := loadedByID[file.ID]
if paths == nil {
paths = make(map[string]struct{})
loadedByID[file.ID] = paths
}
paths[filepath.Clean(file.Path)] = struct{}{}
}
var errs []error
for _, candidate := range candidates {
paths := loadedByID[candidate.ID]
if len(paths) == 0 {
continue
}
if _, selected := paths[filepath.Clean(candidate.Path)]; selected {
continue
}
if errRemove := os.Remove(candidate.Path); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) {
errs = append(errs, errRemove)
log.WithError(errRemove).Warnf("pluginhost: failed to remove old plugin file %s", candidate.Path)
continue
}
log.WithFields(pluginLogFields(candidate.ID, "", candidate.Version, candidate.Path)).Info("pluginhost: old plugin file removed")
}
return errors.Join(errs...)
}
// DiscoverPluginFiles returns plugin binaries selected by the current host discovery rules.
func DiscoverPluginFiles(root string, desiredVersions ...map[string]string) ([]PluginFileInfo, error) {
files, errSelect := selectPluginFiles(root, desiredVersions...)
if errSelect != nil {
return nil, errSelect
}
out := make([]PluginFileInfo, 0, len(files))
for _, file := range files {
out = append(out, PluginFileInfo{
ID: file.ID,
Path: file.Path,
Version: file.Version,
})
}
return out, nil
}
func candidateDirs(root, goos, goarch string) []string {
dirs := make([]string, 0, 2)
dirs = append(dirs, filepath.Join(root, goos, goarch))
dirs = append(dirs, root)
return dirs
}

View file

@ -0,0 +1,221 @@
package pluginhost
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestCandidateDirs(t *testing.T) {
got := candidateDirs("plugins", "darwin", "arm64")
want := []string{
filepath.Join("plugins", "darwin", "arm64"),
"plugins",
}
if len(got) != len(want) {
t.Fatalf("len(candidateDirs) = %d, want %d", len(got), len(want))
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("candidateDirs[%d] = %q, want %q", index, got[index], want[index])
}
}
}
func TestPluginExtensionForPlatform(t *testing.T) {
cases := []struct {
goos string
want string
}{
{goos: "linux", want: ".so"},
{goos: "freebsd", want: ".so"},
{goos: "darwin", want: ".dylib"},
{goos: "windows", want: ".dll"},
}
for _, tc := range cases {
if got := pluginExtension(tc.goos); got != tc.want {
t.Fatalf("pluginExtension(%q) = %q, want %q", tc.goos, got, tc.want)
}
}
}
func TestPluginIDFromDynamicLibraryPath(t *testing.T) {
cases := map[string]string{
"plugins/example.so": "example",
"plugins/example.dylib": "example",
"plugins/example.dll": "example",
"plugins/example.custom": "example.custom",
}
for path, want := range cases {
if got := pluginIDFromPath(path); got != want {
t.Fatalf("pluginIDFromPath(%q) = %q, want %q", path, got, want)
}
}
}
func TestSelectPluginFilesFiltersInvalidIDAndDeduplicatesByID(t *testing.T) {
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)
}
extension := pluginExtension(runtime.GOOS)
paths := []string{
filepath.Join(root, "sample"+extension),
filepath.Join(archDir, "sample"+extension),
filepath.Join(archDir, "bad name"+extension),
filepath.Join(archDir, "-bad"+extension),
filepath.Join(archDir, "another"+strings.ToUpper(extension)),
filepath.Join(archDir, "ignored.txt"),
}
for _, path := range paths {
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
}
}
if errMkdir := os.Mkdir(filepath.Join(archDir, "dir"+extension), 0o755); errMkdir != nil {
t.Fatalf("Mkdir() error = %v", errMkdir)
}
files, errSelect := selectPluginFiles(root)
if errSelect != nil {
t.Fatalf("selectPluginFiles() error = %v", errSelect)
}
want := []pluginFile{
{ID: "another", Path: filepath.Join(archDir, "another"+strings.ToUpper(extension))},
{ID: "sample", Path: filepath.Join(archDir, "sample"+extension)},
}
if len(files) != len(want) {
t.Fatalf("selectPluginFiles() = %v, want %v", files, want)
}
for index := range want {
if files[index] != want[index] {
t.Fatalf("selectPluginFiles()[%d] = %v, want %v", index, files[index], want[index])
}
}
}
func TestSelectPluginFilesPrefersPlatformDirOverRootFallback(t *testing.T) {
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)
}
extension := pluginExtension(runtime.GOOS)
platformPath := filepath.Join(archDir, "alpha"+extension)
rootPath := filepath.Join(root, "alpha"+extension)
for _, path := range []string{rootPath, platformPath} {
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
}
}
files, errSelect := selectPluginFiles(root)
if errSelect != nil {
t.Fatalf("selectPluginFiles() error = %v", errSelect)
}
if len(files) != 1 {
t.Fatalf("selectPluginFiles() = %v, want exactly one alpha plugin", files)
}
if files[0] != (pluginFile{ID: "alpha", Path: platformPath}) {
t.Fatalf("selectPluginFiles()[0] = %v, want platform plugin %s", files[0], platformPath)
}
}
func TestDiscoverPluginFilesReturnsSelectedPluginFiles(t *testing.T) {
root := makePluginDir(t, "alpha")
files, errDiscover := DiscoverPluginFiles(root)
if errDiscover != nil {
t.Fatalf("DiscoverPluginFiles() error = %v", errDiscover)
}
if len(files) != 1 || files[0].ID != "alpha" || files[0].Path == "" {
t.Fatalf("DiscoverPluginFiles() = %#v, want alpha file", files)
}
}
func TestSelectPluginFilesPrefersConfiguredVersionOverHigherVersion(t *testing.T) {
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)
}
extension := pluginExtension(runtime.GOOS)
olderPath := filepath.Join(archDir, "alpha-v1.0.3"+extension)
newerPath := filepath.Join(archDir, "alpha-v1.0.4"+extension)
for _, path := range []string{olderPath, newerPath} {
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
}
}
files, errSelect := selectPluginFiles(root, map[string]string{"alpha": "1.0.3"})
if errSelect != nil {
t.Fatalf("selectPluginFiles() error = %v", errSelect)
}
if len(files) != 1 {
t.Fatalf("selectPluginFiles() = %v, want exactly one alpha plugin", files)
}
if files[0] != (pluginFile{ID: "alpha", Path: olderPath, Version: "1.0.3"}) {
t.Fatalf("selectPluginFiles()[0] = %v, want configured plugin %s", files[0], olderPath)
}
}
func TestSelectPluginFilesFallsBackToHighestVersionWithoutConfiguredVersion(t *testing.T) {
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)
}
extension := pluginExtension(runtime.GOOS)
olderPath := filepath.Join(archDir, "alpha-v1.0.3"+extension)
newerPath := filepath.Join(archDir, "alpha-v1.0.4"+extension)
for _, path := range []string{olderPath, newerPath} {
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
}
}
files, errSelect := selectPluginFiles(root)
if errSelect != nil {
t.Fatalf("selectPluginFiles() error = %v", errSelect)
}
if len(files) != 1 {
t.Fatalf("selectPluginFiles() = %v, want exactly one alpha plugin", files)
}
if files[0] != (pluginFile{ID: "alpha", Path: newerPath, Version: "1.0.4"}) {
t.Fatalf("selectPluginFiles()[0] = %v, want highest plugin %s", files[0], newerPath)
}
}
func TestSelectPluginFilesSkipsPluginWhenConfiguredVersionIsMissing(t *testing.T) {
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)
}
extension := pluginExtension(runtime.GOOS)
path := filepath.Join(archDir, "alpha-v1.0.4"+extension)
if errWriteFile := os.WriteFile(path, []byte("x"), 0o644); errWriteFile != nil {
t.Fatalf("WriteFile(%s) error = %v", path, errWriteFile)
}
files, errSelect := selectPluginFiles(root, map[string]string{"alpha": "1.0.3"})
if errSelect != nil {
t.Fatalf("selectPluginFiles() error = %v", errSelect)
}
if len(files) != 0 {
t.Fatalf("selectPluginFiles() = %v, want no selected alpha plugin", files)
}
}

View file

@ -0,0 +1,154 @@
package pluginhost
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
// pluginRefreshCompatExecutor keeps native OpenAI-compat inference while
// routing credential refresh to a plugin AuthProvider.
//
// Plugins often set Attributes["base_url"] so host routing uses the built-in
// OpenAI-compat executor. That binding previously swallowed refresh because
// OpenAICompatExecutor.Refresh is a no-op for non-Home providers. This wrapper
// preserves native Execute* paths and delegates Refresh to Host.RefreshAuth.
type pluginRefreshCompatExecutor struct {
inner coreauth.ProviderExecutor
host *Host
cfg *config.Config
provider string
}
// NewPluginRefreshCompatExecutor wraps a native provider executor so Refresh is
// handled by the plugin AuthProvider for the same provider key.
func NewPluginRefreshCompatExecutor(inner coreauth.ProviderExecutor, host *Host, cfg *config.Config) coreauth.ProviderExecutor {
if inner == nil {
return nil
}
provider := strings.ToLower(strings.TrimSpace(inner.Identifier()))
return &pluginRefreshCompatExecutor{
inner: inner,
host: host,
cfg: cfg,
provider: provider,
}
}
// IsPluginRefreshCompatExecutor reports whether executor is a plugin-refresh wrapper.
func IsPluginRefreshCompatExecutor(executor coreauth.ProviderExecutor) bool {
_, ok := executor.(*pluginRefreshCompatExecutor)
return ok
}
// UnwrapPluginRefreshCompatExecutor returns the inner native executor when executor
// is a plugin-refresh wrapper.
func UnwrapPluginRefreshCompatExecutor(executor coreauth.ProviderExecutor) (coreauth.ProviderExecutor, bool) {
wrapper, ok := executor.(*pluginRefreshCompatExecutor)
if !ok || wrapper == nil || wrapper.inner == nil {
return nil, false
}
return wrapper.inner, true
}
func (e *pluginRefreshCompatExecutor) Identifier() string {
if e == nil {
return ""
}
if e.provider != "" {
return e.provider
}
if e.inner != nil {
return e.inner.Identifier()
}
return ""
}
func (e *pluginRefreshCompatExecutor) Execute(ctx context.Context, auth *coreauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
if e == nil || e.inner == nil {
return cliproxyexecutor.Response{}, fmt.Errorf("plugin refresh compat executor is unavailable")
}
return e.inner.Execute(ctx, auth, req, opts)
}
func (e *pluginRefreshCompatExecutor) ExecuteStream(ctx context.Context, auth *coreauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
if e == nil || e.inner == nil {
return nil, fmt.Errorf("plugin refresh compat executor is unavailable")
}
return e.inner.ExecuteStream(ctx, auth, req, opts)
}
func (e *pluginRefreshCompatExecutor) CountTokens(ctx context.Context, auth *coreauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
if e == nil || e.inner == nil {
return cliproxyexecutor.Response{}, fmt.Errorf("plugin refresh compat executor is unavailable")
}
return e.inner.CountTokens(ctx, auth, req, opts)
}
func (e *pluginRefreshCompatExecutor) HttpRequest(ctx context.Context, auth *coreauth.Auth, req *http.Request) (*http.Response, error) {
if e == nil || e.inner == nil {
return nil, fmt.Errorf("plugin refresh compat executor is unavailable")
}
return e.inner.HttpRequest(ctx, auth, req)
}
// PrepareRequest forwards credential injection to the inner executor when supported.
func (e *pluginRefreshCompatExecutor) PrepareRequest(req *http.Request, auth *coreauth.Auth) error {
if e == nil || e.inner == nil {
return fmt.Errorf("plugin refresh compat executor is unavailable")
}
preparer, ok := e.inner.(interface {
PrepareRequest(*http.Request, *coreauth.Auth) error
})
if !ok || preparer == nil {
return nil
}
return preparer.PrepareRequest(req, auth)
}
func (e *pluginRefreshCompatExecutor) Refresh(ctx context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
if e == nil {
return nil, fmt.Errorf("plugin refresh compat executor is unavailable")
}
if ctx == nil {
ctx = context.Background()
}
if refreshed, handled, errHome := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
return refreshed, errHome
}
if e.host != nil {
if refreshed, handled, errRefresh := e.host.RefreshAuth(ctx, auth); handled {
return refreshed, errRefresh
}
}
if authHasRefreshToken(auth) {
provider := e.Identifier()
if provider == "" && auth != nil {
provider = strings.TrimSpace(auth.Provider)
}
return nil, fmt.Errorf("plugin auth provider refresh is unavailable for provider %s", provider)
}
if auth == nil {
return nil, nil
}
return auth.Clone(), nil
}
func authHasRefreshToken(auth *coreauth.Auth) bool {
if auth == nil || auth.Metadata == nil {
return false
}
if token, _ := auth.Metadata["refresh_token"].(string); strings.TrimSpace(token) != "" {
return true
}
if token, _ := auth.Metadata["refreshToken"].(string); strings.TrimSpace(token) != "" {
return true
}
return false
}

View file

@ -0,0 +1,176 @@
package pluginhost
import (
"context"
"net/http"
"strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type stubCompatExecutor struct {
id string
executeCalls int
refreshCalls int
}
func (e *stubCompatExecutor) Identifier() string { return e.id }
func (e *stubCompatExecutor) Execute(context.Context, *coreauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
e.executeCalls++
return cliproxyexecutor.Response{Payload: []byte(`{"ok":true}`)}, nil
}
func (e *stubCompatExecutor) ExecuteStream(context.Context, *coreauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
return &cliproxyexecutor.StreamResult{}, nil
}
func (e *stubCompatExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
e.refreshCalls++
return auth, nil
}
func (e *stubCompatExecutor) CountTokens(context.Context, *coreauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
return cliproxyexecutor.Response{}, nil
}
func (e *stubCompatExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {
return nil, nil
}
func (e *stubCompatExecutor) PrepareRequest(*http.Request, *coreauth.Auth) error {
return nil
}
func TestPluginRefreshCompatExecutorDelegatesExecuteAndRefresh(t *testing.T) {
refreshCalls := 0
host := newHostWithRecords(capabilityRecord{
id: "auth-plugin",
plugin: pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
AuthProvider: fakeAuthProvider{
identifier: "plugin-provider",
refreshAuth: func(ctx context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
refreshCalls++
if req.AuthID != "auth-1" || req.AuthProvider != "plugin-provider" {
t.Fatalf("RefreshAuth request = %#v", req)
}
return pluginapi.AuthRefreshResponse{
Auth: pluginapi.AuthData{
ID: "auth-1",
Provider: "plugin-provider",
Metadata: map[string]any{
"access_token": "new-token",
"refresh_token": "refresh-1",
},
Attributes: map[string]string{
"base_url": "https://compat.example.com/v1",
},
},
}, nil
},
},
},
},
})
inner := &stubCompatExecutor{id: "plugin-provider"}
wrapped := NewPluginRefreshCompatExecutor(inner, host, &config.Config{})
if wrapped == nil {
t.Fatal("NewPluginRefreshCompatExecutor() = nil")
}
if !IsPluginRefreshCompatExecutor(wrapped) {
t.Fatal("IsPluginRefreshCompatExecutor() = false, want true")
}
if got, ok := UnwrapPluginRefreshCompatExecutor(wrapped); !ok || got != inner {
t.Fatalf("UnwrapPluginRefreshCompatExecutor() = (%T, %v), want inner", got, ok)
}
if wrapped.Identifier() != "plugin-provider" {
t.Fatalf("Identifier() = %q, want plugin-provider", wrapped.Identifier())
}
auth := &coreauth.Auth{
ID: "auth-1",
Provider: "plugin-provider",
Metadata: map[string]any{
"access_token": "old-token",
"refresh_token": "refresh-1",
},
Attributes: map[string]string{
"base_url": "https://compat.example.com/v1",
},
}
if _, errExecute := wrapped.Execute(context.Background(), auth, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}); errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if inner.executeCalls != 1 {
t.Fatalf("inner Execute calls = %d, want 1", inner.executeCalls)
}
refreshed, errRefresh := wrapped.Refresh(context.Background(), auth)
if errRefresh != nil {
t.Fatalf("Refresh() error = %v", errRefresh)
}
if refreshCalls != 1 {
t.Fatalf("plugin RefreshAuth calls = %d, want 1", refreshCalls)
}
if inner.refreshCalls != 0 {
t.Fatalf("inner Refresh calls = %d, want 0", inner.refreshCalls)
}
if refreshed == nil || refreshed.Metadata["access_token"] != "new-token" {
t.Fatalf("Refresh() auth = %#v, want updated access_token", refreshed)
}
if refreshed.Attributes["base_url"] != "https://compat.example.com/v1" {
t.Fatalf("Refresh() base_url = %q, want preserved", refreshed.Attributes["base_url"])
}
}
func TestPluginRefreshCompatExecutorErrorsWhenRefreshUnavailable(t *testing.T) {
inner := &stubCompatExecutor{id: "plugin-provider"}
wrapped := NewPluginRefreshCompatExecutor(inner, New(), &config.Config{})
auth := &coreauth.Auth{
ID: "auth-1",
Provider: "plugin-provider",
Metadata: map[string]any{
"access_token": "old-token",
"refresh_token": "refresh-1",
},
}
_, errRefresh := wrapped.Refresh(context.Background(), auth)
if errRefresh == nil {
t.Fatal("Refresh() error = nil, want unavailable plugin refresh error")
}
if !strings.Contains(errRefresh.Error(), "plugin auth provider refresh is unavailable") {
t.Fatalf("Refresh() error = %v, want unavailable message", errRefresh)
}
if inner.refreshCalls != 0 {
t.Fatalf("inner Refresh calls = %d, want 0", inner.refreshCalls)
}
}
func TestPluginRefreshCompatExecutorNoOpForAPIKeyAuth(t *testing.T) {
inner := &stubCompatExecutor{id: "plugin-provider"}
wrapped := NewPluginRefreshCompatExecutor(inner, New(), &config.Config{})
auth := &coreauth.Auth{
ID: "auth-1",
Provider: "plugin-provider",
Attributes: map[string]string{
"api_key": "sk-test",
"base_url": "https://compat.example.com/v1",
},
}
refreshed, errRefresh := wrapped.Refresh(context.Background(), auth)
if errRefresh != nil {
t.Fatalf("Refresh() error = %v", errRefresh)
}
if refreshed == nil || refreshed.Attributes["api_key"] != "sk-test" {
t.Fatalf("Refresh() auth = %#v, want unchanged api key auth", refreshed)
}
}

View file

@ -0,0 +1,164 @@
package pluginhost
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestRequestInterceptorTerminationStopsChain(t *testing.T) {
lowCalls := 0
host := newHostWithRecords(
capabilityRecord{
id: "high",
priority: 20,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
RequestInterceptor: requestInterceptorFunc(func(context.Context, pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
return pluginapi.RequestInterceptResponse{
Terminate: true,
StatusCode: http.StatusForbidden,
ResponseHeaders: http.Header{"Content-Type": {"application/json"}},
ResponseBody: []byte(`{"error":"blocked"}`),
}, nil
}),
}},
},
capabilityRecord{
id: "low",
priority: 10,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
RequestInterceptor: requestInterceptorFunc(func(context.Context, pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
lowCalls++
return pluginapi.RequestInterceptResponse{}, nil
}),
}},
},
)
response := host.InterceptRequestBeforeAuth(context.Background(), pluginapi.RequestInterceptRequest{RequestID: "request-1"})
if !response.Terminate || response.StatusCode != http.StatusForbidden {
t.Fatalf("termination response = %#v", response)
}
if response.ResponseHeaders.Get("Content-Type") != "application/json" || string(response.ResponseBody) != `{"error":"blocked"}` {
t.Fatalf("termination payload = %#v", response)
}
if lowCalls != 0 {
t.Fatalf("lower-priority interceptor calls = %d, want 0", lowCalls)
}
}
func TestCompleteRequestUsesUncancelledContextAndClonesMetadata(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
originalNested := map[string]any{"value": "original"}
var got pluginapi.RequestCompletion
var callbackContextError error
done := make(chan struct{})
host := newHostWithRecords(capabilityRecord{
id: "lifecycle",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
RequestLifecyclePlugin: requestLifecyclePluginFunc(func(callbackCtx context.Context, completion pluginapi.RequestCompletion) {
callbackContextError = callbackCtx.Err()
got = completion
completion.Metadata["nested"].(map[string]any)["value"] = "mutated"
close(done)
}),
}},
})
host.CompleteRequest(ctx, pluginapi.RequestCompletion{
RequestID: "request-1",
Outcome: pluginapi.RequestCompletionCanceled,
StartedAt: time.Now().Add(-time.Second),
CompletedAt: time.Now(),
Metadata: map[string]any{"nested": originalNested},
})
<-done
if callbackContextError != nil {
t.Fatalf("callback context error = %v", callbackContextError)
}
if got.RequestID != "request-1" || got.Outcome != pluginapi.RequestCompletionCanceled {
t.Fatalf("completion = %#v", got)
}
if originalNested["value"] != "original" {
t.Fatalf("input metadata was mutated: %#v", originalNested)
}
}
func TestCompleteRequestDoesNotWaitForBlockingPlugin(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
host := newHostWithRecords(capabilityRecord{
id: "blocking-lifecycle",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
RequestLifecyclePlugin: requestLifecyclePluginFunc(func(context.Context, pluginapi.RequestCompletion) {
close(started)
<-release
}),
}},
})
returned := make(chan struct{})
go func() {
host.CompleteRequest(context.Background(), pluginapi.RequestCompletion{RequestID: "request-blocking"})
close(returned)
}()
select {
case <-returned:
case <-time.After(time.Second):
t.Fatal("CompleteRequest blocked on lifecycle plugin")
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("lifecycle plugin was not invoked")
}
close(release)
}
func TestRPCCapabilitiesAndAdapterIncludeRequestLifecycle(t *testing.T) {
var got pluginapi.RequestCompletion
plugin := validTestPlugin("request-lifecycle")
plugin.Capabilities.RequestLifecyclePlugin = requestLifecyclePluginFunc(func(_ context.Context, completion pluginapi.RequestCompletion) {
got = completion
})
caps := rpcCapabilitiesFromPlugin(plugin)
if !caps.RequestLifecyclePlugin {
t.Fatal("RequestLifecyclePlugin = false, want true")
}
rawCaps, errMarshal := json.Marshal(caps)
if errMarshal != nil {
t.Fatalf("Marshal() error = %v", errMarshal)
}
var decoded map[string]any
if errUnmarshal := json.Unmarshal(rawCaps, &decoded); errUnmarshal != nil {
t.Fatalf("Unmarshal() error = %v", errUnmarshal)
}
if decoded["request_lifecycle_plugin"] != true {
t.Fatalf("request_lifecycle_plugin = %#v", decoded["request_lifecycle_plugin"])
}
lookup := newTestSymbolLookup(&testPlugin{registerResult: plugin})
registered, errRegister := registerRPCPlugin(context.Background(), nil, "request-lifecycle", lookup, pluginabi.MethodPluginRegister, nil)
if errRegister != nil {
t.Fatalf("registerRPCPlugin() error = %v", errRegister)
}
if registered.Capabilities.RequestLifecyclePlugin == nil {
t.Fatal("RequestLifecyclePlugin = nil, want RPC adapter")
}
if errComplete := registered.Capabilities.RequestLifecyclePlugin.HandleRequestComplete(context.Background(), pluginapi.RequestCompletion{
RequestID: "request-rpc",
Outcome: pluginapi.RequestCompletionSucceeded,
}); errComplete != nil {
t.Fatalf("HandleRequestComplete() error = %v", errComplete)
}
if got.RequestID != "request-rpc" || got.Outcome != pluginapi.RequestCompletionSucceeded {
t.Fatalf("RPC completion = %#v", got)
}
}

View file

@ -0,0 +1,590 @@
package pluginhost
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type rpcPluginAdapter struct {
id string
host *Host
client pluginClient
}
type rpcAuthProvider struct {
*rpcPluginAdapter
}
type rpcFrontendAuthProvider struct {
*rpcPluginAdapter
}
type rpcProviderExecutor struct {
*rpcPluginAdapter
}
type rpcThinkingApplier struct {
*rpcPluginAdapter
}
type rpcPluginError struct {
message string
statusCode int
}
func (e rpcPluginError) Error() string {
return e.message
}
func (e rpcPluginError) StatusCode() int {
return e.statusCode
}
type rpcResponseNormalizer struct {
*rpcPluginAdapter
method string
}
func registerRPCPlugin(ctx context.Context, host *Host, id string, client pluginClient, method string, configYAML []byte) (pluginapi.Plugin, error) {
if client == nil {
return pluginapi.Plugin{}, fmt.Errorf("plugin client is nil")
}
resp, errCall := callPlugin[rpcRegistration](ctx, client, method, rpcLifecycleRequest{
ConfigYAML: bytes.Clone(configYAML),
SchemaVersion: pluginabi.SchemaVersion,
})
if errCall != nil {
return pluginapi.Plugin{}, errCall
}
if resp.SchemaVersion > pluginabi.SchemaVersion {
return pluginapi.Plugin{}, fmt.Errorf("plugin schema version %d is not supported", resp.SchemaVersion)
}
adapter := &rpcPluginAdapter{id: id, host: host, client: client}
schemaVersion := resp.SchemaVersion
if schemaVersion == 0 {
// Missing schema_version is treated as the original contract.
schemaVersion = 1
}
plugin := pluginapi.Plugin{
Metadata: resp.Metadata,
SchemaVersion: schemaVersion,
Capabilities: pluginapi.Capabilities{
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 {
plugin.Capabilities.ModelRegistrar = adapter
}
if resp.Capabilities.ModelProvider {
plugin.Capabilities.ModelProvider = adapter
}
if resp.Capabilities.AuthProvider {
plugin.Capabilities.AuthProvider = rpcAuthProvider{rpcPluginAdapter: adapter}
}
if resp.Capabilities.FrontendAuthProvider {
plugin.Capabilities.FrontendAuthProvider = rpcFrontendAuthProvider{rpcPluginAdapter: adapter}
}
if resp.Capabilities.Scheduler {
plugin.Capabilities.Scheduler = adapter
}
if resp.Capabilities.ModelRouter {
plugin.Capabilities.ModelRouter = adapter
}
if resp.Capabilities.Executor {
plugin.Capabilities.Executor = rpcProviderExecutor{rpcPluginAdapter: adapter}
}
if resp.Capabilities.RequestTranslator {
plugin.Capabilities.RequestTranslator = adapter
}
if resp.Capabilities.RequestNormalizer {
plugin.Capabilities.RequestNormalizer = adapter
}
if resp.Capabilities.RequestInterceptor {
plugin.Capabilities.RequestInterceptor = adapter
}
if resp.Capabilities.RequestLifecyclePlugin {
plugin.Capabilities.RequestLifecyclePlugin = adapter
}
if resp.Capabilities.ResponseTranslator {
plugin.Capabilities.ResponseTranslator = adapter
}
if resp.Capabilities.ResponseBeforeTranslator {
plugin.Capabilities.ResponseBeforeTranslator = rpcResponseNormalizer{rpcPluginAdapter: adapter, method: pluginabi.MethodResponseNormalizeBefore}
}
if resp.Capabilities.ResponseAfterTranslator {
plugin.Capabilities.ResponseAfterTranslator = rpcResponseNormalizer{rpcPluginAdapter: adapter, method: pluginabi.MethodResponseNormalizeAfter}
}
if resp.Capabilities.ResponseInterceptor {
plugin.Capabilities.ResponseInterceptor = adapter
}
if resp.Capabilities.StreamChunkInterceptor {
plugin.Capabilities.StreamChunkInterceptor = adapter
}
if resp.Capabilities.ThinkingApplier {
plugin.Capabilities.ThinkingApplier = rpcThinkingApplier{rpcPluginAdapter: adapter}
}
if resp.Capabilities.UsagePlugin {
plugin.Capabilities.UsagePlugin = adapter
}
if resp.Capabilities.CommandLinePlugin {
plugin.Capabilities.CommandLinePlugin = adapter
}
if resp.Capabilities.ManagementAPI {
plugin.Capabilities.ManagementAPI = adapter
}
return plugin, nil
}
func callPlugin[T any](ctx context.Context, client pluginClient, method string, request any) (T, error) {
var zero T
rawRequest, errMarshal := json.Marshal(sanitizePluginRequest(request))
if errMarshal != nil {
return zero, fmt.Errorf("marshal plugin request %s: %w", method, errMarshal)
}
rawResp, errCall := client.Call(ctx, method, rawRequest)
if errCall != nil {
return zero, errCall
}
var envelope pluginabi.Envelope
if errUnmarshal := json.Unmarshal(rawResp, &envelope); errUnmarshal != nil {
return zero, fmt.Errorf("decode plugin envelope %s: %w", method, errUnmarshal)
}
out, errDecode := decodeEnvelopeResult[T](envelope)
if errDecode != nil {
if !envelope.OK {
return zero, errDecode
}
return zero, fmt.Errorf("decode plugin result %s: %w", method, errDecode)
}
return out, nil
}
func sanitizePluginRequest(request any) any {
switch req := request.(type) {
case pluginapi.AuthLoginStartRequest:
req.HTTPClient = nil
return req
case pluginapi.AuthLoginPollRequest:
req.HTTPClient = nil
return req
case pluginapi.AuthRefreshRequest:
req.HTTPClient = nil
return req
case pluginapi.AuthModelRequest:
req.HTTPClient = nil
return req
case pluginapi.SchedulerPickRequest:
req.Options.Metadata = sanitizePluginMetadata(req.Options.Metadata)
for index := range req.Candidates {
req.Candidates[index].Metadata = sanitizePluginMetadata(req.Candidates[index].Metadata)
}
return req
case pluginapi.ModelRouteRequest:
req.Metadata = sanitizePluginMetadata(req.Metadata)
return req
case pluginapi.ExecutorRequest:
req.HTTPClient = nil
req.Metadata = sanitizePluginMetadata(req.Metadata)
return req
case pluginapi.RequestInterceptRequest:
req.Metadata = sanitizePluginMetadata(req.Metadata)
return req
case pluginapi.RequestCompletion:
req.Metadata = sanitizePluginMetadata(req.Metadata)
return req
case pluginapi.ResponseInterceptRequest:
req.Metadata = sanitizePluginMetadata(req.Metadata)
return req
case pluginapi.StreamChunkInterceptRequest:
req.Metadata = sanitizePluginMetadata(req.Metadata)
return req
case rpcRequestInterceptRequest:
req.Metadata = sanitizePluginMetadata(req.Metadata)
return req
case rpcModelRouteRequest:
req.Metadata = sanitizePluginMetadata(req.Metadata)
return req
case rpcRequestCompletion:
req.Metadata = sanitizePluginMetadata(req.Metadata)
return req
case rpcResponseInterceptRequest:
req.Metadata = sanitizePluginMetadata(req.Metadata)
return req
case rpcStreamChunkInterceptRequest:
req.Metadata = sanitizePluginMetadata(req.Metadata)
return req
case pluginapi.ExecutorHTTPRequest:
req.HTTPClient = nil
return req
case rpcExecutorRequest:
req.HTTPClient = nil
req.Metadata = sanitizePluginMetadata(req.Metadata)
return req
default:
return request
}
}
func sanitizePluginMetadata(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 {
if sanitized, ok := sanitizePluginMetadataValue(value); ok {
dst[key] = sanitized
}
}
if len(dst) == 0 {
return nil
}
return dst
}
func sanitizePluginMetadataValue(value any) (any, bool) {
switch v := value.(type) {
case nil, string, bool, float64, float32,
int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64:
return value, true
case map[string]any:
return sanitizePluginMetadata(v), true
case []any:
out := make([]any, 0, len(v))
for _, item := range v {
if sanitized, ok := sanitizePluginMetadataValue(item); ok {
out = append(out, sanitized)
}
}
return out, true
default:
// RPC metadata crosses a JSON envelope, so unsupported Go values are normalized to JSON-compatible shapes.
raw, errMarshal := json.Marshal(value)
if errMarshal != nil {
return nil, false
}
var decoded any
if errUnmarshal := json.Unmarshal(raw, &decoded); errUnmarshal != nil {
return nil, false
}
return decoded, true
}
}
func decodeRPCEnvelope[T any](raw []byte) (T, error) {
var zero T
var envelope pluginabi.Envelope
if errUnmarshal := json.Unmarshal(raw, &envelope); errUnmarshal != nil {
return zero, errUnmarshal
}
return decodeEnvelopeResult[T](envelope)
}
func isPluginErrorEnvelope(raw []byte) bool {
var envelope pluginabi.Envelope
if errUnmarshal := json.Unmarshal(raw, &envelope); errUnmarshal != nil {
return false
}
return !envelope.OK && envelope.Error != nil
}
func decodeEnvelopeResult[T any](envelope pluginabi.Envelope) (T, error) {
var zero T
if !envelope.OK {
if envelope.Error != nil {
message := strings.TrimSpace(envelope.Error.Message)
if message == "" {
message = "plugin call failed"
}
if envelope.Error.HTTPStatus > 0 {
return zero, rpcPluginError{message: message, statusCode: envelope.Error.HTTPStatus}
}
return zero, fmt.Errorf("%s", message)
}
return zero, fmt.Errorf("plugin call failed")
}
if len(envelope.Result) == 0 {
return zero, nil
}
var out T
if errDecode := json.Unmarshal(envelope.Result, &out); errDecode != nil {
return zero, errDecode
}
return out, nil
}
func marshalRPCEnvelope(result json.RawMessage) ([]byte, error) {
if result == nil {
result = json.RawMessage(`{}`)
}
return json.Marshal(pluginabi.Envelope{OK: true, Result: result})
}
func marshalRPCError(code, message string) []byte {
raw, _ := json.Marshal(pluginabi.Envelope{
OK: false,
Error: &pluginabi.Error{
Code: code,
Message: message,
},
})
return raw
}
func (a *rpcPluginAdapter) openHostCallbackContext(ctx context.Context) (string, func()) {
if a == nil || a.host == nil {
return "", func() {}
}
return a.host.openCallbackContextForPlugin(ctx, a.id)
}
func (a *rpcPluginAdapter) RegisterModels(ctx context.Context, req pluginapi.ModelRegistrationRequest) (pluginapi.ModelRegistrationResponse, error) {
return callPlugin[pluginapi.ModelRegistrationResponse](ctx, a.client, pluginabi.MethodModelRegister, req)
}
func (a *rpcPluginAdapter) StaticModels(ctx context.Context, req pluginapi.StaticModelRequest) (pluginapi.ModelResponse, error) {
return callPlugin[pluginapi.ModelResponse](ctx, a.client, pluginabi.MethodModelStatic, req)
}
func (a *rpcPluginAdapter) ModelsForAuth(ctx context.Context, req pluginapi.AuthModelRequest) (pluginapi.ModelResponse, error) {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
return callPlugin[pluginapi.ModelResponse](ctx, a.client, pluginabi.MethodModelForAuth, rpcAuthModelRequest{
AuthModelRequest: req,
HostCallbackID: callbackID,
})
}
func (a *rpcPluginAdapter) Pick(ctx context.Context, req pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) {
return callPlugin[pluginapi.SchedulerPickResponse](ctx, a.client, pluginabi.MethodSchedulerPick, req)
}
func (a *rpcPluginAdapter) RouteModel(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
return callPlugin[pluginapi.ModelRouteResponse](ctx, a.client, pluginabi.MethodModelRoute, rpcModelRouteRequest{
ModelRouteRequest: req,
HostCallbackID: callbackID,
})
}
func callPluginIdentifier(client pluginClient, method string) string {
resp, errCall := callPlugin[rpcIdentifierResponse](context.Background(), client, method, rpcEmptyResponse{})
if errCall != nil {
return ""
}
return strings.TrimSpace(resp.Identifier)
}
func (a rpcAuthProvider) Identifier() string {
return callPluginIdentifier(a.client, pluginabi.MethodAuthIdentifier)
}
func (a rpcFrontendAuthProvider) Identifier() string {
return callPluginIdentifier(a.client, pluginabi.MethodFrontendAuthIdentifier)
}
func (a rpcProviderExecutor) Identifier() string {
return callPluginIdentifier(a.client, pluginabi.MethodExecutorIdentifier)
}
func (a rpcThinkingApplier) Identifier() string {
return callPluginIdentifier(a.client, pluginabi.MethodThinkingIdentifier)
}
func (a *rpcPluginAdapter) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
return callPlugin[pluginapi.AuthParseResponse](ctx, a.client, pluginabi.MethodAuthParse, req)
}
func (a *rpcPluginAdapter) StartLogin(ctx context.Context, req pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
return callPlugin[pluginapi.AuthLoginStartResponse](ctx, a.client, pluginabi.MethodAuthLoginStart, rpcAuthLoginStartRequest{
AuthLoginStartRequest: req,
HostCallbackID: callbackID,
})
}
func (a *rpcPluginAdapter) PollLogin(ctx context.Context, req pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
return callPlugin[pluginapi.AuthLoginPollResponse](ctx, a.client, pluginabi.MethodAuthLoginPoll, rpcAuthLoginPollRequest{
AuthLoginPollRequest: req,
HostCallbackID: callbackID,
})
}
func (a *rpcPluginAdapter) RefreshAuth(ctx context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
return callPlugin[pluginapi.AuthRefreshResponse](ctx, a.client, pluginabi.MethodAuthRefresh, rpcAuthRefreshRequest{
AuthRefreshRequest: req,
HostCallbackID: callbackID,
})
}
func (a *rpcPluginAdapter) Authenticate(ctx context.Context, req pluginapi.FrontendAuthRequest) (pluginapi.FrontendAuthResponse, error) {
return callPlugin[pluginapi.FrontendAuthResponse](ctx, a.client, pluginabi.MethodFrontendAuthAuthenticate, req)
}
func (a *rpcPluginAdapter) Execute(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
return callPlugin[pluginapi.ExecutorResponse](ctx, a.client, pluginabi.MethodExecutorExecute, rpcExecutorRequest{
ExecutorRequest: req,
HostCallbackID: callbackID,
})
}
func (a *rpcPluginAdapter) CountTokens(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
return callPlugin[pluginapi.ExecutorResponse](ctx, a.client, pluginabi.MethodExecutorCountTokens, rpcExecutorRequest{
ExecutorRequest: req,
HostCallbackID: callbackID,
})
}
func (a *rpcPluginAdapter) HttpRequest(ctx context.Context, req pluginapi.ExecutorHTTPRequest) (pluginapi.ExecutorHTTPResponse, error) {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
return callPlugin[pluginapi.ExecutorHTTPResponse](ctx, a.client, pluginabi.MethodExecutorHTTPRequest, rpcExecutorHTTPRequest{
ExecutorHTTPRequest: req,
HostCallbackID: callbackID,
})
}
func (a *rpcPluginAdapter) TranslateRequest(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) {
return callPlugin[pluginapi.PayloadResponse](ctx, a.client, pluginabi.MethodRequestTranslate, req)
}
func (a *rpcPluginAdapter) NormalizeRequest(ctx context.Context, req pluginapi.RequestTransformRequest) (pluginapi.PayloadResponse, error) {
return callPlugin[pluginapi.PayloadResponse](ctx, a.client, pluginabi.MethodRequestNormalize, req)
}
func (a *rpcPluginAdapter) InterceptRequestBeforeAuth(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
return callPlugin[pluginapi.RequestInterceptResponse](ctx, a.client, pluginabi.MethodRequestInterceptBefore, rpcRequestInterceptRequest{
RequestInterceptRequest: req,
HostCallbackID: callbackID,
})
}
func (a *rpcPluginAdapter) InterceptRequestAfterAuth(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
return callPlugin[pluginapi.RequestInterceptResponse](ctx, a.client, pluginabi.MethodRequestInterceptAfter, rpcRequestInterceptRequest{
RequestInterceptRequest: req,
HostCallbackID: callbackID,
})
}
func (a *rpcPluginAdapter) HandleRequestComplete(ctx context.Context, completion pluginapi.RequestCompletion) error {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
_, errCall := callPlugin[rpcEmptyResponse](ctx, a.client, pluginabi.MethodRequestComplete, rpcRequestCompletion{
RequestCompletion: completion,
HostCallbackID: callbackID,
})
return errCall
}
func (a *rpcPluginAdapter) TranslateResponse(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) {
return callPlugin[pluginapi.PayloadResponse](ctx, a.client, pluginabi.MethodResponseTranslate, req)
}
func (a rpcResponseNormalizer) NormalizeResponse(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) {
return callPlugin[pluginapi.PayloadResponse](ctx, a.client, a.method, req)
}
func (a *rpcPluginAdapter) InterceptResponse(ctx context.Context, req pluginapi.ResponseInterceptRequest) (pluginapi.ResponseInterceptResponse, error) {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
return callPlugin[pluginapi.ResponseInterceptResponse](ctx, a.client, pluginabi.MethodResponseInterceptAfter, rpcResponseInterceptRequest{
ResponseInterceptRequest: req,
HostCallbackID: callbackID,
})
}
func (a *rpcPluginAdapter) InterceptStreamChunk(ctx context.Context, req pluginapi.StreamChunkInterceptRequest) (pluginapi.StreamChunkInterceptResponse, error) {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
return callPlugin[pluginapi.StreamChunkInterceptResponse](ctx, a.client, pluginabi.MethodResponseInterceptStreamChunk, rpcStreamChunkInterceptRequest{
StreamChunkInterceptRequest: req,
HostCallbackID: callbackID,
})
}
func (a rpcThinkingApplier) ApplyThinking(ctx context.Context, req pluginapi.ThinkingApplyRequest) (pluginapi.PayloadResponse, error) {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
return callPlugin[pluginapi.PayloadResponse](ctx, a.client, pluginabi.MethodThinkingApply, rpcThinkingApplyRequest{
ThinkingApplyRequest: req,
HostCallbackID: callbackID,
})
}
func (a *rpcPluginAdapter) HandleUsage(ctx context.Context, record pluginapi.UsageRecord) {
_, _ = callPlugin[rpcEmptyResponse](ctx, a.client, pluginabi.MethodUsageHandle, record)
}
func (a *rpcPluginAdapter) RegisterCommandLine(ctx context.Context, req pluginapi.CommandLineRegistrationRequest) (pluginapi.CommandLineRegistrationResponse, error) {
return callPlugin[pluginapi.CommandLineRegistrationResponse](ctx, a.client, pluginabi.MethodCommandLineRegister, req)
}
func (a *rpcPluginAdapter) ExecuteCommandLine(ctx context.Context, req pluginapi.CommandLineExecutionRequest) (pluginapi.CommandLineExecutionResponse, error) {
return callPlugin[pluginapi.CommandLineExecutionResponse](ctx, a.client, pluginabi.MethodCommandLineExecute, req)
}
func (a *rpcPluginAdapter) RegisterManagement(ctx context.Context, req pluginapi.ManagementRegistrationRequest) (pluginapi.ManagementRegistrationResponse, error) {
resp, errCall := callPlugin[rpcManagementRegistrationResponse](ctx, a.client, pluginabi.MethodManagementRegister, req)
if errCall != nil {
return pluginapi.ManagementRegistrationResponse{}, errCall
}
routes := make([]pluginapi.ManagementRoute, 0, len(resp.Routes))
for _, route := range resp.Routes {
route.Handler = a
routes = append(routes, route)
}
resources := make([]pluginapi.ResourceRoute, 0, len(resp.Resources))
for _, route := range resp.Resources {
route.Handler = a
resources = append(resources, route)
}
return pluginapi.ManagementRegistrationResponse{Routes: routes, Resources: resources}, nil
}
func (a *rpcPluginAdapter) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
callbackID, closeCallback := a.openHostCallbackContext(ctx)
defer closeCallback()
return callPlugin[pluginapi.ManagementResponse](ctx, a.client, pluginabi.MethodManagementHandle, rpcManagementRequest{
ManagementRequest: req,
HostCallbackID: callbackID,
})
}
func httpResponseFromPlugin(resp pluginapi.ExecutorHTTPResponse, req *http.Request) *http.Response {
status := resp.StatusCode
if status == 0 {
status = http.StatusOK
}
return &http.Response{
StatusCode: status,
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
Header: cloneHeader(resp.Headers),
Body: io.NopCloser(bytes.NewReader(bytes.Clone(resp.Body))),
Request: req,
}
}

View file

@ -0,0 +1,82 @@
package pluginhost
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
)
type staticEnvelopePluginClient struct {
raw []byte
}
func (c staticEnvelopePluginClient) Call(context.Context, string, []byte) ([]byte, error) {
return c.raw, nil
}
func (c staticEnvelopePluginClient) Shutdown() {}
func TestDecodeEnvelopeResultPreservesPluginHTTPStatus(t *testing.T) {
_, errDecode := decodeEnvelopeResult[rpcEmptyResponse](pluginabi.Envelope{
OK: false,
Error: &pluginabi.Error{
Code: "plugin_error",
Message: "license required",
HTTPStatus: http.StatusForbidden,
},
})
if errDecode == nil {
t.Fatal("decodeEnvelopeResult returned nil error")
}
if got := errDecode.Error(); got != "license required" {
t.Fatalf("error = %q, want license required", got)
}
statusProvider, ok := errDecode.(interface{ StatusCode() int })
if !ok {
t.Fatalf("error %T does not expose StatusCode", errDecode)
}
if got := statusProvider.StatusCode(); got != http.StatusForbidden {
t.Fatalf("status = %d, want %d", got, http.StatusForbidden)
}
}
func TestCallPluginReturnsPluginErrorWithoutMethodWrapper(t *testing.T) {
raw, errMarshal := json.Marshal(pluginabi.Envelope{
OK: false,
Error: &pluginabi.Error{
Code: "plugin_error",
Message: "license required",
HTTPStatus: http.StatusForbidden,
},
})
if errMarshal != nil {
t.Fatalf("marshal envelope: %v", errMarshal)
}
_, errCall := callPlugin[rpcEmptyResponse](context.Background(), staticEnvelopePluginClient{raw: raw}, pluginabi.MethodExecutorExecuteStream, rpcEmptyResponse{})
if errCall == nil {
t.Fatal("callPlugin returned nil error")
}
if got := errCall.Error(); got != "license required" {
t.Fatalf("error = %q, want license required", got)
}
statusProvider, ok := errCall.(interface{ StatusCode() int })
if !ok {
t.Fatalf("error %T does not expose StatusCode", errCall)
}
if got := statusProvider.StatusCode(); got != http.StatusForbidden {
t.Fatalf("status = %d, want %d", got, http.StatusForbidden)
}
}
func TestIsPluginErrorEnvelopeAcceptsNonzeroReturnEnvelope(t *testing.T) {
raw := marshalRPCError("plugin_error", "upstream failed")
if !isPluginErrorEnvelope(raw) {
t.Fatalf("isPluginErrorEnvelope(%s) = false, want true", raw)
}
if isPluginErrorEnvelope([]byte(`not json`)) {
t.Fatal("isPluginErrorEnvelope accepted invalid JSON")
}
}

View file

@ -0,0 +1,80 @@
package pluginhost
import (
"context"
"fmt"
"sync"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func (a *rpcPluginAdapter) ExecuteStream(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorStreamResponse, error) {
if a == nil || a.host == nil || a.host.streams == nil {
return pluginapi.ExecutorStreamResponse{}, fmt.Errorf("plugin stream bridge is unavailable")
}
streamID, chunks, cleanupStream := a.host.streams.open(ctx)
callbackID, closeCallback := a.openHostCallbackContext(ctx)
cleanup := combinedCleanup(cleanupStream, closeCallback)
rpcReq := rpcExecutorRequest{
ExecutorRequest: req,
StreamID: streamID,
HostCallbackID: callbackID,
}
resp, errCall := callPlugin[rpcExecutorStreamResponse](ctx, a.client, pluginabi.MethodExecutorExecuteStream, rpcReq)
if errCall != nil {
cleanup()
return pluginapi.ExecutorStreamResponse{}, errCall
}
if len(resp.Chunks) > 0 {
cleanup()
out := make(chan pluginapi.ExecutorStreamChunk, len(resp.Chunks))
for _, chunk := range resp.Chunks {
out <- chunk
}
close(out)
return pluginapi.ExecutorStreamResponse{Headers: resp.Headers, Chunks: out}, nil
}
// Async streaming plugins can return before they finish emitting chunks, so keep callbacks alive until the stream ends.
return pluginapi.ExecutorStreamResponse{
Headers: resp.Headers,
Chunks: cleanupWhenStreamDone(ctx, chunks, cleanup),
}, nil
}
func combinedCleanup(cleanups ...func()) func() {
var once sync.Once
return func() {
once.Do(func() {
for _, cleanup := range cleanups {
if cleanup != nil {
cleanup()
}
}
})
}
}
func cleanupWhenStreamDone(ctx context.Context, chunks <-chan pluginapi.ExecutorStreamChunk, cleanup func()) <-chan pluginapi.ExecutorStreamChunk {
out := make(chan pluginapi.ExecutorStreamChunk)
go func() {
defer func() {
if cleanup != nil {
cleanup()
}
close(out)
}()
var done <-chan struct{}
if ctx != nil {
done = ctx.Done()
}
for chunk := range chunks {
select {
case out <- chunk:
case <-done:
return
}
}
}()
return out
}

View file

@ -0,0 +1,127 @@
package pluginhost
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestRPCExecuteStreamKeepsHostCallbackScopeUntilStreamCloses(t *testing.T) {
host := New()
client := newStreamCallbackPluginClient()
adapter := &rpcPluginAdapter{
id: "stream-plugin",
host: host,
client: client,
}
stream, errStream := adapter.ExecuteStream(context.Background(), pluginapi.ExecutorRequest{Stream: true})
if errStream != nil {
t.Fatalf("ExecuteStream() error = %v", errStream)
}
waitForStreamCallbackPlugin(t, client)
if client.callbackID == "" {
t.Fatal("host callback id is empty")
}
if !callbackContextExists(host, client.callbackID) {
t.Fatal("host callback scope closed before plugin stream closed")
}
closeReq, errMarshal := json.Marshal(rpcStreamCloseRequest{StreamID: client.streamID})
if errMarshal != nil {
t.Fatalf("marshal close request: %v", errMarshal)
}
if _, errClose := host.callFromPlugin(context.Background(), pluginabi.MethodHostStreamClose, closeReq); errClose != nil {
t.Fatalf("close stream: %v", errClose)
}
for range stream.Chunks {
}
if callbackContextExists(host, client.callbackID) {
t.Fatal("host callback scope remained open after plugin stream closed")
}
}
func TestRPCExecuteStreamClosesHostCallbackScopeOnContextCancelWhileChunkPending(t *testing.T) {
host := New()
client := newStreamCallbackPluginClient()
adapter := &rpcPluginAdapter{
id: "stream-plugin",
host: host,
client: client,
}
ctx, cancel := context.WithCancel(context.Background())
stream, errStream := adapter.ExecuteStream(ctx, pluginapi.ExecutorRequest{Stream: true})
if errStream != nil {
t.Fatalf("ExecuteStream() error = %v", errStream)
}
waitForStreamCallbackPlugin(t, client)
emitReq, errMarshal := json.Marshal(rpcStreamEmitRequest{StreamID: client.streamID, Payload: []byte("pending")})
if errMarshal != nil {
t.Fatalf("marshal emit request: %v", errMarshal)
}
if _, errEmit := host.callFromPlugin(context.Background(), pluginabi.MethodHostStreamEmit, emitReq); errEmit != nil {
t.Fatalf("emit stream: %v", errEmit)
}
cancel()
for range stream.Chunks {
}
if callbackContextExists(host, client.callbackID) {
t.Fatal("host callback scope remained open after context cancel")
}
}
func callbackContextExists(host *Host, callbackID string) bool {
if host == nil || host.callbackContexts == nil {
return false
}
host.callbackContexts.mu.RLock()
_, exists := host.callbackContexts.contexts[callbackID]
host.callbackContexts.mu.RUnlock()
return exists
}
type streamCallbackPluginClient struct {
called chan struct{}
streamID string
callbackID string
}
func newStreamCallbackPluginClient() *streamCallbackPluginClient {
return &streamCallbackPluginClient{called: make(chan struct{})}
}
func (c *streamCallbackPluginClient) Call(ctx context.Context, method string, request []byte) ([]byte, error) {
if method != pluginabi.MethodExecutorExecuteStream {
return nil, fmt.Errorf("method = %s, want %s", method, pluginabi.MethodExecutorExecuteStream)
}
var req rpcExecutorRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode executor stream request: %w", errUnmarshal)
}
c.streamID = req.StreamID
c.callbackID = req.HostCallbackID
close(c.called)
return marshalRPCResult(rpcExecutorStreamResponse{
Headers: http.Header{"Content-Type": []string{"text/event-stream"}},
})
}
func (c *streamCallbackPluginClient) Shutdown() {}
func waitForStreamCallbackPlugin(t *testing.T, client *streamCallbackPluginClient) {
t.Helper()
select {
case <-client.called:
case <-time.After(time.Second):
t.Fatal("plugin stream method was not called")
}
}

View file

@ -0,0 +1,166 @@
package pluginhost
import (
"encoding/json"
"net/http"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type rpcLifecycleRequest struct {
ConfigYAML []byte `json:"config_yaml"`
SchemaVersion uint32 `json:"schema_version"`
}
type rpcRegistration struct {
SchemaVersion uint32 `json:"schema_version"`
Metadata pluginapi.Metadata `json:"metadata"`
Capabilities rpcCapabilities `json:"capabilities"`
}
type rpcCapabilities struct {
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"`
Scheduler bool `json:"scheduler"`
ModelRouter bool `json:"model_router"`
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"`
RequestLifecyclePlugin bool `json:"request_lifecycle_plugin"`
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 {
Identifier string `json:"identifier"`
}
type rpcExecutorStreamResponse struct {
Headers http.Header `json:"headers,omitempty"`
Chunks []pluginapi.ExecutorStreamChunk `json:"chunks,omitempty"`
}
type rpcAuthLoginStartRequest struct {
pluginapi.AuthLoginStartRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcAuthLoginPollRequest struct {
pluginapi.AuthLoginPollRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcAuthRefreshRequest struct {
pluginapi.AuthRefreshRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcAuthModelRequest struct {
pluginapi.AuthModelRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcExecutorRequest struct {
pluginapi.ExecutorRequest
StreamID string `json:"stream_id,omitempty"`
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcExecutorHTTPRequest struct {
pluginapi.ExecutorHTTPRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcRequestInterceptRequest struct {
pluginapi.RequestInterceptRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcModelRouteRequest struct {
pluginapi.ModelRouteRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcRequestCompletion struct {
pluginapi.RequestCompletion
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcResponseInterceptRequest struct {
pluginapi.ResponseInterceptRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcStreamChunkInterceptRequest struct {
pluginapi.StreamChunkInterceptRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcThinkingApplyRequest struct {
pluginapi.ThinkingApplyRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcManagementRequest struct {
pluginapi.ManagementRequest
HostCallbackID string `json:"host_callback_id,omitempty"`
}
type rpcManagementRegistrationResponse struct {
Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`
Resources []pluginapi.ResourceRoute `json:"resources,omitempty"`
}
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,
FrontendAuthProviderExclusive: caps.FrontendAuthProvider != nil && caps.FrontendAuthProviderExclusive,
Scheduler: caps.Scheduler != nil,
ModelRouter: caps.ModelRouter != 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,
RequestLifecyclePlugin: caps.RequestLifecyclePlugin != 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,
}
}
func marshalRPCResult(v any) ([]byte, error) {
result, errMarshal := json.Marshal(v)
if errMarshal != nil {
return nil, errMarshal
}
return marshalRPCEnvelope(json.RawMessage(result))
}

View file

@ -0,0 +1,386 @@
package pluginhost
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"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"])
}
}
func TestRPCCapabilitiesIncludeScheduler(t *testing.T) {
plugin := pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
Scheduler: schedulerFunc(func(context.Context, pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) {
return pluginapi.SchedulerPickResponse{}, nil
}),
},
}
caps := rpcCapabilitiesFromPlugin(plugin)
if !caps.Scheduler {
t.Fatal("Scheduler = 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["scheduler"] != true {
t.Fatalf("scheduler = %#v, want true", decoded["scheduler"])
}
}
func TestRPCCapabilitiesIncludeModelRouter(t *testing.T) {
plugin := pluginapi.Plugin{
Capabilities: pluginapi.Capabilities{
ModelRouter: modelRouterFunc(func(context.Context, pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
return pluginapi.ModelRouteResponse{}, nil
}),
},
}
caps := rpcCapabilitiesFromPlugin(plugin)
if !caps.ModelRouter {
t.Fatal("ModelRouter = 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["model_router"] != true {
t.Fatalf("model_router = %#v, want true", decoded["model_router"])
}
}
func TestRegisterRPCPluginSendsHostSchemaVersion(t *testing.T) {
lookup := newTestSymbolLookup(&testPlugin{
registerResult: validTestPlugin("schema"),
})
registered, errRegister := registerRPCPlugin(context.Background(), nil, "schema", lookup, pluginabi.MethodPluginRegister, []byte("mode: test"))
if errRegister != nil {
t.Fatalf("registerRPCPlugin() error = %v", errRegister)
}
if lookup.lastLifecycle.SchemaVersion != pluginabi.SchemaVersion {
t.Fatalf("lifecycle schema_version = %d, want %d", lookup.lastLifecycle.SchemaVersion, pluginabi.SchemaVersion)
}
if registered.SchemaVersion != pluginabi.SchemaVersion {
t.Fatalf("registered SchemaVersion = %d, want %d", registered.SchemaVersion, pluginabi.SchemaVersion)
}
if string(lookup.lastLifecycle.ConfigYAML) != "mode: test" {
t.Fatalf("lifecycle config = %q, want input config", lookup.lastLifecycle.ConfigYAML)
}
}
func TestRegisterRPCPluginRejectsFutureSchemaVersion(t *testing.T) {
lookup := newTestSymbolLookup(&testPlugin{
registerResult: validTestPlugin("future-schema"),
})
lookup.schemaVersion = pluginabi.SchemaVersion + 1
_, errRegister := registerRPCPlugin(context.Background(), nil, "future-schema", lookup, pluginabi.MethodPluginRegister, nil)
if errRegister == nil || !strings.Contains(errRegister.Error(), "schema version") {
t.Fatalf("registerRPCPlugin() error = %v, want unsupported schema version", errRegister)
}
}
func TestRegisterRPCPluginAcceptsModelRouterOnSchema1(t *testing.T) {
plugin := validTestPlugin("router-schema1")
plugin.Capabilities.ModelRouter = modelRouterFunc(func(context.Context, pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
return pluginapi.ModelRouteResponse{}, nil
})
lookup := newTestSymbolLookup(&testPlugin{registerResult: plugin})
lookup.schemaVersion = 1
registered, errRegister := registerRPCPlugin(context.Background(), nil, "router-schema1", lookup, pluginabi.MethodPluginRegister, nil)
if errRegister != nil {
t.Fatalf("registerRPCPlugin() error = %v, want model_router on schema 1", errRegister)
}
if registered.Capabilities.ModelRouter == nil {
t.Fatal("ModelRouter = nil, want adapter")
}
if registered.SchemaVersion != 1 {
t.Fatalf("registered SchemaVersion = %d, want 1", registered.SchemaVersion)
}
}
func TestRPCModelRouteUsesAdapter(t *testing.T) {
var routeCalls int
var gotReq pluginapi.ModelRouteRequest
lookup := newTestSymbolLookup(&testPlugin{
registerResult: pluginapi.Plugin{
Metadata: pluginapi.Metadata{
Name: "router",
Version: "1.0.0",
Author: "test",
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
},
Capabilities: pluginapi.Capabilities{
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
routeCalls++
gotReq = req
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetExecutor,
Target: "claude-websearch-plugin",
Reason: "typed websearch",
}, nil
}),
},
},
})
plugin, errRegister := registerRPCPlugin(context.Background(), nil, "router", lookup, pluginabi.MethodPluginRegister, nil)
if errRegister != nil {
t.Fatalf("registerRPCPlugin() error = %v", errRegister)
}
if plugin.Capabilities.ModelRouter == nil {
t.Fatal("ModelRouter = nil, want adapter")
}
req := pluginapi.ModelRouteRequest{
SourceFormat: "anthropic",
RequestedModel: "claude-sonnet",
Stream: true,
Headers: map[string][]string{"X-Test": {"one", "two"}},
Query: map[string][]string{"beta": {"true"}},
Body: []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}]}`),
Metadata: map[string]any{
"keep": "value",
},
}
resp, errRoute := plugin.Capabilities.ModelRouter.RouteModel(context.Background(), req)
if errRoute != nil {
t.Fatalf("ModelRouter.RouteModel() error = %v", errRoute)
}
if !resp.Handled || resp.Target != "claude-websearch-plugin" || resp.Reason != "typed websearch" {
t.Fatalf("ModelRouter.RouteModel() response = %#v", resp)
}
if routeCalls != 1 {
t.Fatalf("route calls = %d, want 1", routeCalls)
}
if gotReq.SourceFormat != req.SourceFormat || gotReq.RequestedModel != req.RequestedModel ||
gotReq.Stream != req.Stream || string(gotReq.Body) != string(req.Body) {
t.Fatalf("route request main fields = %#v, want %#v", gotReq, req)
}
if !reflect.DeepEqual(gotReq.Headers, req.Headers) {
t.Fatalf("route request headers = %#v, want %#v", gotReq.Headers, req.Headers)
}
if !reflect.DeepEqual(gotReq.Query, req.Query) {
t.Fatalf("route request query = %#v, want %#v", gotReq.Query, req.Query)
}
if gotReq.Metadata["keep"] != "value" {
t.Fatalf("route request metadata = %#v", gotReq.Metadata)
}
}
func TestRPCSchedulerPickUsesAdapter(t *testing.T) {
var pickCalls int
var gotReq pluginapi.SchedulerPickRequest
lookup := newTestSymbolLookup(&testPlugin{
registerResult: pluginapi.Plugin{
Metadata: pluginapi.Metadata{
Name: "scheduler",
Version: "1.0.0",
Author: "test",
GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
},
Capabilities: pluginapi.Capabilities{
Scheduler: schedulerFunc(func(ctx context.Context, req pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) {
pickCalls++
gotReq = req
return pluginapi.SchedulerPickResponse{
AuthID: "auth-2",
Handled: true,
}, nil
}),
},
},
})
plugin, errRegister := registerRPCPlugin(context.Background(), nil, "scheduler", lookup, pluginabi.MethodPluginRegister, nil)
if errRegister != nil {
t.Fatalf("registerRPCPlugin() error = %v", errRegister)
}
if plugin.Capabilities.Scheduler == nil {
t.Fatal("Scheduler = nil, want adapter")
}
req := pluginapi.SchedulerPickRequest{
Provider: "openai",
Providers: []string{"openai", "codex"},
Model: "gpt-5.4",
Stream: true,
Options: pluginapi.SchedulerOptions{
Headers: map[string][]string{"X-Test": {"one", "two"}},
},
Candidates: []pluginapi.SchedulerAuthCandidate{
{
ID: "auth-1",
Provider: "openai",
Priority: 10,
Status: "ready",
Attributes: map[string]string{"region": "us"},
},
{
ID: "auth-2",
Provider: "codex",
Priority: 20,
Status: "ready",
Attributes: map[string]string{"region": "eu"},
},
},
}
resp, errPick := plugin.Capabilities.Scheduler.Pick(context.Background(), req)
if errPick != nil {
t.Fatalf("Scheduler.Pick() error = %v", errPick)
}
if resp.AuthID != "auth-2" || !resp.Handled {
t.Fatalf("Scheduler.Pick() response = %#v, want auth-2 handled", resp)
}
if pickCalls != 1 {
t.Fatalf("scheduler pick calls = %d, want 1", pickCalls)
}
if gotReq.Provider != req.Provider || !reflect.DeepEqual(gotReq.Providers, req.Providers) ||
gotReq.Model != req.Model || gotReq.Stream != req.Stream {
t.Fatalf("scheduler request main fields = %#v, want %#v", gotReq, req)
}
if !reflect.DeepEqual(gotReq.Options.Headers, req.Options.Headers) {
t.Fatalf("scheduler request headers = %#v, want %#v", gotReq.Options.Headers, req.Options.Headers)
}
if len(gotReq.Candidates) != len(req.Candidates) {
t.Fatalf("scheduler candidates len = %d, want %d", len(gotReq.Candidates), len(req.Candidates))
}
for index := range req.Candidates {
gotCandidate := gotReq.Candidates[index]
wantCandidate := req.Candidates[index]
if gotCandidate.ID != wantCandidate.ID ||
gotCandidate.Provider != wantCandidate.Provider ||
gotCandidate.Priority != wantCandidate.Priority ||
gotCandidate.Status != wantCandidate.Status ||
!reflect.DeepEqual(gotCandidate.Attributes, wantCandidate.Attributes) {
t.Fatalf("scheduler candidate[%d] = %#v, want %#v", index, gotCandidate, wantCandidate)
}
}
}
func TestSanitizePluginRequestScheduler(t *testing.T) {
req := pluginapi.SchedulerPickRequest{
Provider: "openai",
Providers: []string{"openai", "codex"},
Model: "gpt-5.4",
Stream: true,
Options: pluginapi.SchedulerOptions{
Headers: map[string][]string{"X-Test": {"one", "two"}},
Metadata: map[string]any{
"keep": "value",
"drop": make(chan struct{}),
},
},
Candidates: []pluginapi.SchedulerAuthCandidate{
{
ID: "auth-1",
Provider: "openai",
Priority: 10,
Status: "ready",
Attributes: map[string]string{"region": "us"},
Metadata: map[string]any{
"keep": "candidate",
"drop": make(chan struct{}),
},
},
},
}
raw, errMarshal := json.Marshal(sanitizePluginRequest(req))
if errMarshal != nil {
t.Fatalf("Marshal(sanitized scheduler request) error = %v", errMarshal)
}
var decoded pluginapi.SchedulerPickRequest
if errUnmarshal := json.Unmarshal(raw, &decoded); errUnmarshal != nil {
t.Fatalf("Unmarshal(sanitized scheduler request) error = %v", errUnmarshal)
}
if decoded.Provider != req.Provider || !reflect.DeepEqual(decoded.Providers, req.Providers) ||
decoded.Model != req.Model || decoded.Stream != req.Stream {
t.Fatalf("scheduler request main fields = %#v, want %#v", decoded, req)
}
if !reflect.DeepEqual(decoded.Options.Headers, req.Options.Headers) {
t.Fatalf("scheduler request headers = %#v, want %#v", decoded.Options.Headers, req.Options.Headers)
}
if decoded.Options.Metadata["keep"] != "value" {
t.Fatalf("scheduler options metadata keep = %#v, want value", decoded.Options.Metadata["keep"])
}
if _, ok := decoded.Options.Metadata["drop"]; ok {
t.Fatalf("scheduler options metadata drop survived sanitize: %#v", decoded.Options.Metadata)
}
if len(decoded.Candidates) != 1 {
t.Fatalf("scheduler candidates len = %d, want 1", len(decoded.Candidates))
}
gotCandidate := decoded.Candidates[0]
wantCandidate := req.Candidates[0]
if gotCandidate.ID != wantCandidate.ID ||
gotCandidate.Provider != wantCandidate.Provider ||
gotCandidate.Priority != wantCandidate.Priority ||
gotCandidate.Status != wantCandidate.Status ||
!reflect.DeepEqual(gotCandidate.Attributes, wantCandidate.Attributes) {
t.Fatalf("scheduler candidate = %#v, want %#v", gotCandidate, wantCandidate)
}
if gotCandidate.Metadata["keep"] != "candidate" {
t.Fatalf("scheduler candidate metadata keep = %#v, want candidate", gotCandidate.Metadata["keep"])
}
if _, ok := gotCandidate.Metadata["drop"]; ok {
t.Fatalf("scheduler candidate metadata drop survived sanitize: %#v", gotCandidate.Metadata)
}
}

View file

@ -0,0 +1,111 @@
package pluginhost
import (
"context"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
log "github.com/sirupsen/logrus"
)
func (h *Host) PickAuth(ctx context.Context, req pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, bool, error) {
record := h.schedulerRecord()
if record == nil {
return pluginapi.SchedulerPickResponse{}, false, nil
}
resp, handled, errPick := h.callScheduler(ctx, *record, req)
if errPick != nil || !handled {
return resp, handled, errPick
}
if !resp.Handled {
return pluginapi.SchedulerPickResponse{}, false, nil
}
resp, valid, reason := normalizeSchedulerResponse(resp, req)
if !valid {
log.WithField("plugin_id", record.id).Warnf("pluginhost: scheduler returned invalid response: %s", reason)
return pluginapi.SchedulerPickResponse{}, false, nil
}
return resp, true, nil
}
func (h *Host) HasScheduler() bool {
return h.schedulerRecord() != nil
}
func (h *Host) schedulerRecord() *capabilityRecord {
if h == nil {
return nil
}
for _, record := range h.activeRecords() {
if h.isPluginFused(record.id) || record.plugin.Capabilities.Scheduler == nil {
continue
}
copyRecord := record
return &copyRecord
}
return nil
}
func (h *Host) callScheduler(ctx context.Context, record capabilityRecord, req pluginapi.SchedulerPickRequest) (resp pluginapi.SchedulerPickResponse, handled bool, err error) {
scheduler := record.plugin.Capabilities.Scheduler
if h == nil || scheduler == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.SchedulerPickResponse{}, false, nil
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "Scheduler.Pick", recovered)
resp = pluginapi.SchedulerPickResponse{}
handled = false
err = nil
}
}()
req.Plugin = record.meta
resp, errPick := scheduler.Pick(ctx, req)
if errPick != nil {
log.WithField("plugin_id", record.id).WithError(errPick).Warn("pluginhost: scheduler rejected auth pick")
return pluginapi.SchedulerPickResponse{}, true, errPick
}
return resp, true, nil
}
func normalizeSchedulerResponse(resp pluginapi.SchedulerPickResponse, req pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, bool, string) {
resp.AuthID = strings.TrimSpace(resp.AuthID)
resp.DelegateBuiltin = strings.TrimSpace(resp.DelegateBuiltin)
hasAuthID := resp.AuthID != ""
hasDelegate := resp.DelegateBuiltin != ""
if !hasAuthID && !hasDelegate {
return pluginapi.SchedulerPickResponse{}, false, "missing auth id or delegate"
}
if hasAuthID {
if !schedulerCandidateExists(req.Candidates, resp.AuthID) {
return pluginapi.SchedulerPickResponse{}, false, "unknown auth id"
}
return resp, true, ""
}
if !validSchedulerBuiltin(resp.DelegateBuiltin) {
return pluginapi.SchedulerPickResponse{}, false, "unknown delegate"
}
return resp, true, ""
}
func schedulerCandidateExists(candidates []pluginapi.SchedulerAuthCandidate, authID string) bool {
for _, candidate := range candidates {
if strings.TrimSpace(candidate.ID) == authID {
return true
}
}
return false
}
func validSchedulerBuiltin(delegate string) bool {
switch delegate {
case pluginapi.SchedulerBuiltinRoundRobin, pluginapi.SchedulerBuiltinFillFirst:
return true
default:
return false
}
}

View file

@ -0,0 +1,217 @@
package pluginhost
import (
"context"
"errors"
"strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
func TestHostPickAuthUsesHighestPrioritySchedulerOnly(t *testing.T) {
var highCalls int
var lowCalls int
host := newHostWithRecords(
capabilityRecord{
id: "low",
priority: 1,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{Scheduler: schedulerFunc(func(context.Context, pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) {
lowCalls++
return pluginapi.SchedulerPickResponse{Handled: true, AuthID: "auth-low"}, nil
})}},
},
capabilityRecord{
id: "high",
priority: 10,
meta: pluginapi.Metadata{Name: "high", Version: "1.0.0"},
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{Scheduler: schedulerFunc(func(ctx context.Context, req pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) {
highCalls++
if req.Plugin.Name != "high" {
t.Fatalf("req.Plugin.Name = %q, want high", req.Plugin.Name)
}
return pluginapi.SchedulerPickResponse{Handled: true, AuthID: "auth-high"}, nil
})}},
},
)
resp, handled, errPick := host.PickAuth(context.Background(), schedulerRequest("auth-high", "auth-low"))
if errPick != nil {
t.Fatalf("PickAuth() error = %v, want nil", errPick)
}
if !handled {
t.Fatal("PickAuth() handled = false, want true")
}
if resp.AuthID != "auth-high" {
t.Fatalf("PickAuth() AuthID = %q, want auth-high", resp.AuthID)
}
if highCalls != 1 {
t.Fatalf("high calls = %d, want 1", highCalls)
}
if lowCalls != 0 {
t.Fatalf("low calls = %d, want 0", lowCalls)
}
}
func TestHostPickAuthReturnsSchedulerError(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "scheduler",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{Scheduler: schedulerFunc(func(context.Context, pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) {
return pluginapi.SchedulerPickResponse{}, errors.New("tenant quota exhausted")
})}},
})
_, handled, errPick := host.PickAuth(context.Background(), schedulerRequest("auth-1"))
if !handled {
t.Fatal("PickAuth() handled = false, want true")
}
if errPick == nil || !strings.Contains(errPick.Error(), "tenant quota exhausted") {
t.Fatalf("PickAuth() error = %v, want tenant quota exhausted", errPick)
}
}
func TestHostPickAuthPanicFusesAndFallsBack(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "scheduler",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{Scheduler: schedulerFunc(func(context.Context, pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) {
panic("boom")
})}},
})
_, handled, errPick := host.PickAuth(context.Background(), schedulerRequest("auth-1"))
if handled {
t.Fatal("PickAuth() handled = true, want false")
}
if errPick != nil {
t.Fatalf("PickAuth() error = %v, want nil", errPick)
}
if !host.isPluginFused("scheduler") {
t.Fatal("scheduler plugin was not fused after panic")
}
}
func TestHostPickAuthUnhandledDoesNotCallLowerPriorityScheduler(t *testing.T) {
var lowCalls int
host := newHostWithRecords(
capabilityRecord{
id: "low",
priority: 1,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{Scheduler: schedulerFunc(func(context.Context, pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) {
lowCalls++
return pluginapi.SchedulerPickResponse{Handled: true, AuthID: "auth-low"}, nil
})}},
},
capabilityRecord{
id: "high",
priority: 10,
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{Scheduler: schedulerFunc(func(context.Context, pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) {
return pluginapi.SchedulerPickResponse{Handled: false}, nil
})}},
},
)
_, handled, errPick := host.PickAuth(context.Background(), schedulerRequest("auth-low"))
if errPick != nil {
t.Fatalf("PickAuth() error = %v, want nil", errPick)
}
if handled {
t.Fatal("PickAuth() handled = true, want false")
}
if lowCalls != 0 {
t.Fatalf("low calls = %d, want 0", lowCalls)
}
}
func TestHostPickAuthInvalidResponseFallsBack(t *testing.T) {
tests := []struct {
name string
resp pluginapi.SchedulerPickResponse
}{
{
name: "unknown auth id",
resp: pluginapi.SchedulerPickResponse{Handled: true, AuthID: "missing"},
},
{
name: "unknown delegate",
resp: pluginapi.SchedulerPickResponse{Handled: true, DelegateBuiltin: "unknown"},
},
{
name: "handled without decision",
resp: pluginapi.SchedulerPickResponse{Handled: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "scheduler",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{Scheduler: schedulerFunc(func(context.Context, pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) {
return tt.resp, nil
})}},
})
_, handled, errPick := host.PickAuth(context.Background(), schedulerRequest("auth-1"))
if errPick != nil {
t.Fatalf("PickAuth() error = %v, want nil", errPick)
}
if handled {
t.Fatal("PickAuth() handled = true, want false")
}
})
}
}
func TestHostPickAuthPrefersValidAuthIDOverInvalidDelegate(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "scheduler",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{Scheduler: schedulerFunc(func(context.Context, pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) {
return pluginapi.SchedulerPickResponse{Handled: true, AuthID: "auth-a", DelegateBuiltin: "unknown"}, nil
})}},
})
resp, handled, errPick := host.PickAuth(context.Background(), schedulerRequest("auth-a"))
if errPick != nil {
t.Fatalf("PickAuth() error = %v, want nil", errPick)
}
if !handled {
t.Fatal("PickAuth() handled = false, want true")
}
if resp.AuthID != "auth-a" {
t.Fatalf("PickAuth() AuthID = %q, want auth-a", resp.AuthID)
}
}
func TestHostPickAuthAllowsKnownBuiltinDelegates(t *testing.T) {
for _, delegate := range []string{pluginapi.SchedulerBuiltinRoundRobin, pluginapi.SchedulerBuiltinFillFirst} {
t.Run(delegate, func(t *testing.T) {
host := newHostWithRecords(capabilityRecord{
id: "scheduler",
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{Scheduler: schedulerFunc(func(context.Context, pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, error) {
return pluginapi.SchedulerPickResponse{Handled: true, DelegateBuiltin: delegate}, nil
})}},
})
resp, handled, errPick := host.PickAuth(context.Background(), schedulerRequest("auth-1"))
if errPick != nil {
t.Fatalf("PickAuth() error = %v, want nil", errPick)
}
if !handled {
t.Fatal("PickAuth() handled = false, want true")
}
if resp.DelegateBuiltin != delegate {
t.Fatalf("PickAuth() DelegateBuiltin = %q, want %q", resp.DelegateBuiltin, delegate)
}
})
}
}
func schedulerRequest(ids ...string) pluginapi.SchedulerPickRequest {
req := pluginapi.SchedulerPickRequest{
Provider: "test",
Model: "test-model",
}
for _, id := range ids {
req.Candidates = append(req.Candidates, pluginapi.SchedulerAuthCandidate{ID: id})
}
return req
}

View file

@ -0,0 +1,160 @@
package pluginhost
import (
"sort"
"strings"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type capabilityRecord struct {
id string
path string
version string
priority int
meta pluginapi.Metadata
plugin pluginapi.Plugin
}
type Snapshot struct {
enabled bool
records []capabilityRecord
}
// RegisteredPluginInfo describes a plugin that is active in the current runtime snapshot.
type RegisteredPluginInfo struct {
ID string
Priority int
Metadata pluginapi.Metadata
SupportsOAuth bool
OAuthProvider string
Menus []RegisteredPluginMenu
}
// RegisteredPluginMenu describes a plugin-owned resource menu entry.
type RegisteredPluginMenu struct {
Path string
Menu string
Description string
}
func emptySnapshot() *Snapshot {
return &Snapshot{}
}
func (h *Host) activeRecords() []capabilityRecord {
return h.activeRecordsFromSnapshot(h.Snapshot())
}
func (h *Host) activeRecordsFromSnapshot(snap *Snapshot) []capabilityRecord {
if snap == nil || len(snap.records) == 0 {
return nil
}
out := make([]capabilityRecord, 0, len(snap.records))
for _, record := range snap.records {
if h.recordCurrent(record) {
out = append(out, record)
}
}
return out
}
// RegisteredPlugins returns a stable copy of plugin metadata in the current runtime snapshot.
func (h *Host) RegisteredPlugins() []RegisteredPluginInfo {
records := h.activeRecords()
if len(records) == 0 {
return nil
}
menusByPlugin := h.registeredPluginMenus()
out := make([]RegisteredPluginInfo, 0, len(records))
for _, record := range records {
authProvider := record.plugin.Capabilities.AuthProvider
oauthProvider := ""
if authProvider != nil && !h.isPluginFused(record.id) {
if identifier, okIdentifier := h.callAuthProviderIdentifier(record.id, authProvider); okIdentifier {
oauthProvider = identifier
}
}
out = append(out, RegisteredPluginInfo{
ID: record.id,
Priority: record.priority,
Metadata: clonePluginMetadata(record.meta),
SupportsOAuth: authProvider != nil,
OAuthProvider: oauthProvider,
Menus: menusByPlugin[record.id],
})
}
return out
}
// PluginRegistered reports whether a plugin is active in the current runtime snapshot.
func (h *Host) PluginRegistered(id string) bool {
if h == nil {
return false
}
id = strings.TrimSpace(id)
if id == "" {
return false
}
for _, record := range h.activeRecords() {
if record.id == id {
return true
}
}
return false
}
func (h *Host) registeredPluginMenus() map[string][]RegisteredPluginMenu {
out := make(map[string][]RegisteredPluginMenu)
if h == nil {
return out
}
h.mu.Lock()
defer h.mu.Unlock()
for _, record := range h.resourceRoutes {
menu := strings.TrimSpace(record.route.Menu)
if menu == "" {
continue
}
out[record.pluginID] = append(out[record.pluginID], RegisteredPluginMenu{
Path: strings.TrimSpace(record.route.Path),
Menu: menu,
Description: strings.TrimSpace(record.route.Description),
})
}
for pluginID := range out {
sort.SliceStable(out[pluginID], func(i, j int) bool {
return out[pluginID][i].Path < out[pluginID][j].Path
})
}
return out
}
func sortRecords(records []capabilityRecord) {
sort.SliceStable(records, func(i, j int) bool {
if records[i].priority == records[j].priority {
return records[i].id < records[j].id
}
return records[i].priority > records[j].priority
})
}
func clonePluginMetadata(meta pluginapi.Metadata) pluginapi.Metadata {
if len(meta.ConfigFields) == 0 {
return meta
}
meta.ConfigFields = cloneConfigFields(meta.ConfigFields)
return meta
}
func cloneConfigFields(fields []pluginapi.ConfigField) []pluginapi.ConfigField {
if len(fields) == 0 {
return nil
}
out := make([]pluginapi.ConfigField, len(fields))
copy(out, fields)
for index := range out {
out[index].EnumValues = append([]string(nil), fields[index].EnumValues...)
}
return out
}

View file

@ -0,0 +1,243 @@
package pluginhost
import (
"context"
"errors"
"fmt"
"strconv"
"sync"
"sync/atomic"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type streamBridge struct {
next atomic.Uint64
mu sync.Mutex
streams map[string]*streamBridgeStream
}
const streamBridgeBufferSize = 16
var errStreamBridgeClosed = errors.New("stream is not open")
type streamBridgeStream struct {
chunks chan pluginapi.ExecutorStreamChunk
emits chan streamBridgeEmit
closes chan streamBridgeClose
closed chan struct{}
finished chan struct{}
abort chan struct{}
closeOnce sync.Once
abortOnce sync.Once
}
type streamBridgeEmit struct {
ctx context.Context
chunk pluginapi.ExecutorStreamChunk
done chan error
}
type streamBridgeClose struct {
errorMessage string
accepted chan struct{}
}
type rpcStreamEmitRequest struct {
StreamID string `json:"stream_id"`
Payload []byte `json:"payload,omitempty"`
Error string `json:"error,omitempty"`
}
type rpcStreamCloseRequest struct {
StreamID string `json:"stream_id"`
Error string `json:"error,omitempty"`
}
func newStreamBridge() *streamBridge {
return &streamBridge{streams: make(map[string]*streamBridgeStream)}
}
func newStreamBridgeStream() *streamBridgeStream {
stream := &streamBridgeStream{
chunks: make(chan pluginapi.ExecutorStreamChunk),
emits: make(chan streamBridgeEmit),
closes: make(chan streamBridgeClose),
closed: make(chan struct{}),
finished: make(chan struct{}),
abort: make(chan struct{}),
}
go stream.run()
return stream
}
func (s *streamBridgeStream) run() {
defer func() {
s.markClosed()
close(s.chunks)
close(s.finished)
}()
queue := make([]pluginapi.ExecutorStreamChunk, 0, streamBridgeBufferSize)
for {
var emitC <-chan streamBridgeEmit
if len(queue) < streamBridgeBufferSize {
emitC = s.emits
}
var outputC chan pluginapi.ExecutorStreamChunk
var next pluginapi.ExecutorStreamChunk
if len(queue) > 0 {
outputC = s.chunks
next = queue[0]
}
select {
case <-s.abort:
return
case request := <-s.closes:
s.markClosed()
close(request.accepted)
if request.errorMessage != "" {
queue = append(queue, pluginapi.ExecutorStreamChunk{Err: fmt.Errorf("%s", request.errorMessage)})
}
for len(queue) > 0 {
select {
case <-s.abort:
return
case s.chunks <- queue[0]:
queue = queue[1:]
}
}
return
case request := <-emitC:
if err := request.ctx.Err(); err != nil {
request.done <- err
continue
}
queue = append(queue, request.chunk)
request.done <- nil
case outputC <- next:
queue = queue[1:]
}
}
}
func (s *streamBridgeStream) markClosed() {
if s == nil {
return
}
s.closeOnce.Do(func() { close(s.closed) })
}
func (s *streamBridgeStream) abortStream() {
if s == nil {
return
}
s.abortOnce.Do(func() {
s.markClosed()
close(s.abort)
})
}
func (s *streamBridgeStream) emit(ctx context.Context, chunk pluginapi.ExecutorStreamChunk) error {
if s == nil {
return errStreamBridgeClosed
}
if ctx == nil {
ctx = context.Background()
}
request := streamBridgeEmit{
ctx: ctx,
chunk: chunk,
done: make(chan error, 1),
}
select {
case <-ctx.Done():
return ctx.Err()
case <-s.closed:
return errStreamBridgeClosed
case s.emits <- request:
}
return <-request.done
}
func (s *streamBridgeStream) close(errorMessage string) {
if s == nil {
return
}
request := streamBridgeClose{
errorMessage: errorMessage,
accepted: make(chan struct{}),
}
select {
case <-s.finished:
return
case s.closes <- request:
}
select {
case <-request.accepted:
case <-s.finished:
}
}
func (b *streamBridge) open(ctx context.Context) (string, <-chan pluginapi.ExecutorStreamChunk, func()) {
if b == nil {
chunks := make(chan pluginapi.ExecutorStreamChunk)
close(chunks)
return "", chunks, func() {}
}
id := strconv.FormatUint(b.next.Add(1), 10)
stream := newStreamBridgeStream()
b.mu.Lock()
b.streams[id] = stream
b.mu.Unlock()
cleanup := func() {
b.mu.Lock()
if b.streams[id] == stream {
delete(b.streams, id)
}
b.mu.Unlock()
stream.abortStream()
}
if ctx != nil && ctx.Done() != nil {
// Abort streams canceled before ExecuteStream can install cleanupWhenStreamDone.
go func() {
<-ctx.Done()
cleanup()
}()
}
return id, stream.chunks, cleanup
}
func (b *streamBridge) emit(ctx context.Context, id string, chunk pluginapi.ExecutorStreamChunk) error {
if b == nil || id == "" {
return fmt.Errorf("stream id is required")
}
b.mu.Lock()
stream := b.streams[id]
b.mu.Unlock()
if stream == nil {
return fmt.Errorf("stream %s is not open", id)
}
if err := stream.emit(ctx, chunk); err != nil {
if errors.Is(err, errStreamBridgeClosed) {
return fmt.Errorf("stream %s is not open", id)
}
return err
}
return nil
}
func (b *streamBridge) close(id string, errorMessage string) {
if b == nil || id == "" {
return
}
b.mu.Lock()
stream := b.streams[id]
delete(b.streams, id)
b.mu.Unlock()
if stream == nil {
return
}
stream.close(errorMessage)
}

View file

@ -0,0 +1,197 @@
package pluginhost
import (
"context"
"strings"
"sync"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type streamBridgeNotifyContext struct {
context.Context
ready chan struct{}
once sync.Once
}
func (c *streamBridgeNotifyContext) Done() <-chan struct{} {
c.once.Do(func() { close(c.ready) })
return c.Context.Done()
}
func TestStreamBridgeCloseUnblocksPendingEmit(t *testing.T) {
bridge := newStreamBridge()
streamID, chunks, _ := bridge.open(context.Background())
for range streamBridgeBufferSize {
if err := bridge.emit(context.Background(), streamID, pluginapi.ExecutorStreamChunk{Payload: []byte("buffered")}); err != nil {
t.Fatalf("fill stream buffer: %v", err)
}
}
emitCtx := &streamBridgeNotifyContext{
Context: context.Background(),
ready: make(chan struct{}),
}
emitDone := make(chan error, 1)
go func() {
emitDone <- bridge.emit(emitCtx, streamID, pluginapi.ExecutorStreamChunk{Payload: []byte("blocked")})
}()
select {
case <-emitCtx.ready:
case <-time.After(time.Second):
t.Fatal("emit did not reach the blocked send")
}
select {
case err := <-emitDone:
t.Fatalf("emit returned while the stream buffer was full: %v", err)
default:
}
bridge.close(streamID, "")
select {
case err := <-emitDone:
if err == nil || !strings.Contains(err.Error(), "is not open") {
t.Fatalf("emit error = %v, want stream-not-open error", err)
}
case <-time.After(time.Second):
t.Fatal("close did not unblock the pending emit")
}
chunkCount := 0
for range chunks {
chunkCount++
}
if chunkCount != streamBridgeBufferSize {
t.Fatalf("delivered chunks = %d, want %d buffered chunks without the rejected emit", chunkCount, streamBridgeBufferSize)
}
}
func TestStreamBridgeEmitUsesAcceptedPumpResultAfterContextCancellation(t *testing.T) {
for range 1000 {
ctx, cancel := context.WithCancel(context.Background())
stream := &streamBridgeStream{
emits: make(chan streamBridgeEmit),
closed: make(chan struct{}),
}
go func() {
request := <-stream.emits
cancel()
request.done <- nil
}()
if err := stream.emit(ctx, pluginapi.ExecutorStreamChunk{Payload: []byte("accepted")}); err != nil {
t.Fatalf("accepted emit returned error: %v", err)
}
}
}
func TestStreamBridgeAbortClosesSaturatedStreamWithoutConsumer(t *testing.T) {
bridge := newStreamBridge()
streamID, chunks, cleanup := bridge.open(context.Background())
bridge.mu.Lock()
stream := bridge.streams[streamID]
bridge.mu.Unlock()
for range streamBridgeBufferSize {
if err := bridge.emit(context.Background(), streamID, pluginapi.ExecutorStreamChunk{Payload: []byte("buffered")}); err != nil {
t.Fatalf("fill stream buffer: %v", err)
}
}
cleanup()
select {
case <-stream.finished:
case <-time.After(time.Second):
t.Fatal("abort left the saturated stream pump running")
}
if _, ok := <-chunks; ok {
t.Fatal("aborted stream retained buffered chunks")
}
}
func TestStreamBridgeCleanupAbortsPendingGracefulClose(t *testing.T) {
bridge := newStreamBridge()
streamID, chunks, cleanup := bridge.open(context.Background())
bridge.mu.Lock()
stream := bridge.streams[streamID]
bridge.mu.Unlock()
for range streamBridgeBufferSize {
if err := bridge.emit(context.Background(), streamID, pluginapi.ExecutorStreamChunk{Payload: []byte("buffered")}); err != nil {
t.Fatalf("fill stream buffer: %v", err)
}
}
bridge.close(streamID, "plugin stream failed")
cleanup()
select {
case <-stream.finished:
case <-time.After(time.Second):
t.Fatal("cleanup did not abort the graceful close after the stream was removed")
}
if _, ok := <-chunks; ok {
t.Fatal("cleanup retained queued chunks after aborting the graceful close")
}
}
func TestStreamBridgeCloseDeliversTerminalError(t *testing.T) {
bridge := newStreamBridge()
streamID, chunks, _ := bridge.open(context.Background())
bridge.close(streamID, "plugin stream failed")
chunk, ok := <-chunks
if !ok {
t.Fatal("stream closed before terminal error")
}
if chunk.Err == nil || chunk.Err.Error() != "plugin stream failed" {
t.Fatalf("terminal error = %v, want plugin stream failed", chunk.Err)
}
if _, ok = <-chunks; ok {
t.Fatal("stream remains open after terminal error")
}
}
func TestStreamBridgeClosePreservesTerminalErrorWhenBufferIsFull(t *testing.T) {
bridge := newStreamBridge()
streamID, chunks, _ := bridge.open(context.Background())
for range streamBridgeBufferSize {
if err := bridge.emit(context.Background(), streamID, pluginapi.ExecutorStreamChunk{Payload: []byte("buffered")}); err != nil {
t.Fatalf("fill stream buffer: %v", err)
}
}
closeDone := make(chan struct{})
go func() {
bridge.close(streamID, "plugin stream failed")
close(closeDone)
}()
select {
case <-closeDone:
case <-time.After(time.Second):
t.Fatal("close blocked on the saturated stream")
}
chunkCount := 0
var terminalErr error
for chunk := range chunks {
chunkCount++
if chunk.Err != nil {
terminalErr = chunk.Err
}
}
if chunkCount != streamBridgeBufferSize+1 {
t.Fatalf("delivered chunks = %d, want %d buffered chunks plus terminal error", chunkCount, streamBridgeBufferSize+1)
}
if terminalErr == nil || terminalErr.Error() != "plugin stream failed" {
t.Fatalf("terminal error = %v, want plugin stream failed", terminalErr)
}
}

View file

@ -0,0 +1,6 @@
package pluginhost
// SupportPluginHeaderValue reports whether the current binary was built with CGO enabled.
func SupportPluginHeaderValue() string {
return supportPluginValue
}

View file

@ -0,0 +1,5 @@
//go:build cgo
package pluginhost
const supportPluginValue = "1"

View file

@ -0,0 +1,5 @@
//go:build !cgo
package pluginhost
const supportPluginValue = "0"

View file

@ -0,0 +1,392 @@
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) {
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{})
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],
}
}