Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
18
backend/internal/pluginhost/abi.go
Normal file
18
backend/internal/pluginhost/abi.go
Normal 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)
|
||||
}
|
||||
501
backend/internal/pluginhost/adapters.go
Normal file
501
backend/internal/pluginhost/adapters.go
Normal 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 ®istry.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 ®istry.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, ©Model)
|
||||
}
|
||||
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),
|
||||
})
|
||||
}
|
||||
149
backend/internal/pluginhost/adapters_auth.go
Normal file
149
backend/internal/pluginhost/adapters_auth.go
Normal 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
|
||||
}
|
||||
948
backend/internal/pluginhost/adapters_executors.go
Normal file
948
backend/internal/pluginhost/adapters_executors.go
Normal 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, ¶m)
|
||||
return
|
||||
}
|
||||
if chunk.Err != nil {
|
||||
_ = sendExecutorPluginStreamChunk(ctx, out, chunk)
|
||||
continue
|
||||
}
|
||||
frames := a.translateExecutorStreamPayload(ctx, prepared, chunk.Payload, ¶m)
|
||||
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
|
||||
}
|
||||
565
backend/internal/pluginhost/adapters_interceptors.go
Normal file
565
backend/internal/pluginhost/adapters_interceptors.go
Normal 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
|
||||
}
|
||||
3540
backend/internal/pluginhost/adapters_test.go
Normal file
3540
backend/internal/pluginhost/adapters_test.go
Normal file
File diff suppressed because it is too large
Load diff
369
backend/internal/pluginhost/adapters_usage_translation.go
Normal file
369
backend/internal/pluginhost/adapters_usage_translation.go
Normal 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
|
||||
}
|
||||
652
backend/internal/pluginhost/auth_callbacks.go
Normal file
652
backend/internal/pluginhost/auth_callbacks.go
Normal 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
|
||||
}
|
||||
277
backend/internal/pluginhost/auth_callbacks_test.go
Normal file
277
backend/internal/pluginhost/auth_callbacks_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
599
backend/internal/pluginhost/auth_provider.go
Normal file
599
backend/internal/pluginhost/auth_provider.go
Normal 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 ©Record
|
||||
}
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
482
backend/internal/pluginhost/auth_provider_test.go
Normal file
482
backend/internal/pluginhost/auth_provider_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
139
backend/internal/pluginhost/callback_contexts.go
Normal file
139
backend/internal/pluginhost/callback_contexts.go
Normal 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)
|
||||
}
|
||||
128
backend/internal/pluginhost/client_guard.go
Normal file
128
backend/internal/pluginhost/client_guard.go
Normal 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():
|
||||
}
|
||||
}
|
||||
70
backend/internal/pluginhost/client_guard_test.go
Normal file
70
backend/internal/pluginhost/client_guard_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
420
backend/internal/pluginhost/command_line.go
Normal file
420
backend/internal/pluginhost/command_line.go
Normal 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)
|
||||
}
|
||||
}
|
||||
212
backend/internal/pluginhost/command_line_test.go
Normal file
212
backend/internal/pluginhost/command_line_test.go
Normal 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
|
||||
}
|
||||
229
backend/internal/pluginhost/config.go
Normal file
229
backend/internal/pluginhost/config.go
Normal 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 ©Node
|
||||
}
|
||||
|
||||
func mustMarshalYAML(v any) []byte {
|
||||
raw, errMarshal := yaml.Marshal(v)
|
||||
if errMarshal != nil {
|
||||
return append([]byte(nil), defaultRuntimeConfigYAML...)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
105
backend/internal/pluginhost/config_test.go
Normal file
105
backend/internal/pluginhost/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
139
backend/internal/pluginhost/executor_route.go
Normal file
139
backend/internal/pluginhost/executor_route.go
Normal 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)
|
||||
}
|
||||
873
backend/internal/pluginhost/host.go
Normal file
873
backend/internal/pluginhost/host.go
Normal 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)
|
||||
}
|
||||
356
backend/internal/pluginhost/host_callbacks.go
Normal file
356
backend/internal/pluginhost/host_callbacks.go
Normal 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{})
|
||||
}
|
||||
752
backend/internal/pluginhost/host_callbacks_test.go
Normal file
752
backend/internal/pluginhost/host_callbacks_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
65
backend/internal/pluginhost/host_callbacks_unix.go
Normal file
65
backend/internal/pluginhost/host_callbacks_unix.go
Normal 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)
|
||||
}
|
||||
}
|
||||
87
backend/internal/pluginhost/host_model_stream_callbacks.go
Normal file
87
backend/internal/pluginhost/host_model_stream_callbacks.go
Normal 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{})
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
1766
backend/internal/pluginhost/host_test.go
Normal file
1766
backend/internal/pluginhost/host_test.go
Normal file
File diff suppressed because it is too large
Load diff
172
backend/internal/pluginhost/http_bridge.go
Normal file
172
backend/internal/pluginhost/http_bridge.go
Normal 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
|
||||
}
|
||||
83
backend/internal/pluginhost/http_stream_bridge.go
Normal file
83
backend/internal/pluginhost/http_stream_bridge.go
Normal 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()
|
||||
}
|
||||
}
|
||||
232
backend/internal/pluginhost/loader_unix.go
Normal file
232
backend/internal/pluginhost/loader_unix.go
Normal 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)
|
||||
}
|
||||
15
backend/internal/pluginhost/loader_unsupported.go
Normal file
15
backend/internal/pluginhost/loader_unsupported.go
Normal 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{}
|
||||
}
|
||||
405
backend/internal/pluginhost/loader_windows.go
Normal file
405
backend/internal/pluginhost/loader_windows.go
Normal 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)
|
||||
}
|
||||
231
backend/internal/pluginhost/loader_windows_test.go
Normal file
231
backend/internal/pluginhost/loader_windows_test.go
Normal 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
|
||||
}
|
||||
47
backend/internal/pluginhost/logging.go
Normal file
47
backend/internal/pluginhost/logging.go
Normal 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
|
||||
}
|
||||
56
backend/internal/pluginhost/logging_test.go
Normal file
56
backend/internal/pluginhost/logging_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
363
backend/internal/pluginhost/management.go
Normal file
363
backend/internal/pluginhost/management.go
Normal 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)
|
||||
}
|
||||
276
backend/internal/pluginhost/management_test.go
Normal file
276
backend/internal/pluginhost/management_test.go
Normal 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)
|
||||
}
|
||||
155
backend/internal/pluginhost/model_router.go
Normal file
155
backend/internal/pluginhost/model_router.go
Normal 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)
|
||||
}
|
||||
613
backend/internal/pluginhost/model_router_test.go
Normal file
613
backend/internal/pluginhost/model_router_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
91
backend/internal/pluginhost/model_stream_bridge.go
Normal file
91
backend/internal/pluginhost/model_stream_bridge.go
Normal 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()
|
||||
}
|
||||
}
|
||||
313
backend/internal/pluginhost/platform.go
Normal file
313
backend/internal/pluginhost/platform.go
Normal 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
|
||||
}
|
||||
221
backend/internal/pluginhost/platform_test.go
Normal file
221
backend/internal/pluginhost/platform_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
154
backend/internal/pluginhost/plugin_refresh_compat_executor.go
Normal file
154
backend/internal/pluginhost/plugin_refresh_compat_executor.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
164
backend/internal/pluginhost/request_lifecycle_test.go
Normal file
164
backend/internal/pluginhost/request_lifecycle_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
590
backend/internal/pluginhost/rpc_client.go
Normal file
590
backend/internal/pluginhost/rpc_client.go
Normal 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,
|
||||
}
|
||||
}
|
||||
82
backend/internal/pluginhost/rpc_client_error_test.go
Normal file
82
backend/internal/pluginhost/rpc_client_error_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
80
backend/internal/pluginhost/rpc_client_stream.go
Normal file
80
backend/internal/pluginhost/rpc_client_stream.go
Normal 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
|
||||
}
|
||||
127
backend/internal/pluginhost/rpc_client_stream_test.go
Normal file
127
backend/internal/pluginhost/rpc_client_stream_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
166
backend/internal/pluginhost/rpc_schema.go
Normal file
166
backend/internal/pluginhost/rpc_schema.go
Normal 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))
|
||||
}
|
||||
386
backend/internal/pluginhost/rpc_schema_test.go
Normal file
386
backend/internal/pluginhost/rpc_schema_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
111
backend/internal/pluginhost/scheduler.go
Normal file
111
backend/internal/pluginhost/scheduler.go
Normal 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 ©Record
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
217
backend/internal/pluginhost/scheduler_test.go
Normal file
217
backend/internal/pluginhost/scheduler_test.go
Normal 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
|
||||
}
|
||||
160
backend/internal/pluginhost/snapshot.go
Normal file
160
backend/internal/pluginhost/snapshot.go
Normal 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
|
||||
}
|
||||
243
backend/internal/pluginhost/stream_bridge.go
Normal file
243
backend/internal/pluginhost/stream_bridge.go
Normal 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)
|
||||
}
|
||||
197
backend/internal/pluginhost/stream_bridge_test.go
Normal file
197
backend/internal/pluginhost/stream_bridge_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
6
backend/internal/pluginhost/support.go
Normal file
6
backend/internal/pluginhost/support.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package pluginhost
|
||||
|
||||
// SupportPluginHeaderValue reports whether the current binary was built with CGO enabled.
|
||||
func SupportPluginHeaderValue() string {
|
||||
return supportPluginValue
|
||||
}
|
||||
5
backend/internal/pluginhost/support_cgo.go
Normal file
5
backend/internal/pluginhost/support_cgo.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//go:build cgo
|
||||
|
||||
package pluginhost
|
||||
|
||||
const supportPluginValue = "1"
|
||||
5
backend/internal/pluginhost/support_nocgo.go
Normal file
5
backend/internal/pluginhost/support_nocgo.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//go:build !cgo
|
||||
|
||||
package pluginhost
|
||||
|
||||
const supportPluginValue = "0"
|
||||
392
backend/internal/pluginhost/test_helpers_test.go
Normal file
392
backend/internal/pluginhost/test_helpers_test.go
Normal 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],
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue