diff --git a/common/domain.go b/common/domain.go index fdd6821f..345eebca 100644 --- a/common/domain.go +++ b/common/domain.go @@ -25,6 +25,7 @@ type ChatOptions struct { Raw bool Seed int ModelContextLength int + MaxTokens int } // NormalizeMessages remove empty messages and ensure messages order user-assist-user diff --git a/core/plugin_registry.go b/core/plugin_registry.go index 7b6a5469..ab3d7c16 100644 --- a/core/plugin_registry.go +++ b/core/plugin_registry.go @@ -12,6 +12,7 @@ import ( "github.com/danielmiessler/fabric/plugins/ai/bedrock" "github.com/danielmiessler/fabric/plugins/ai/exolab" + "github.com/danielmiessler/fabric/plugins/ai/perplexity" // Added Perplexity plugin "github.com/danielmiessler/fabric/plugins/strategy" "github.com/samber/lo" @@ -91,6 +92,7 @@ func NewPluginRegistry(db *fsdb.Db) (ret *PluginRegistry, err error) { anthropic.NewClient(), lmstudio.NewClient(), exolab.NewClient(), + perplexity.NewClient(), // Added Perplexity client ) if hasAWSCredentials() { diff --git a/go.mod b/go.mod index ce44803d..2882d8f3 100644 --- a/go.mod +++ b/go.mod @@ -92,6 +92,7 @@ require ( github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect + github.com/sgaunet/perplexity-go/v2 v2.8.0 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect diff --git a/go.sum b/go.sum index 62fb517f..f69c8788 100644 --- a/go.sum +++ b/go.sum @@ -202,6 +202,8 @@ github.com/sashabaranov/go-openai v1.40.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adO github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sgaunet/perplexity-go/v2 v2.8.0 h1:stnuVieniZMGo6qJLCV2JyR2uF7K5398YOA/ZZcgrSg= +github.com/sgaunet/perplexity-go/v2 v2.8.0/go.mod h1:MSks4RNuivCi0GqJyylhFdgSJFVEwZHjAhrf86Wkynk= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= diff --git a/plugins/ai/perplexity/perplexity.go b/plugins/ai/perplexity/perplexity.go new file mode 100644 index 00000000..acf3d85e --- /dev/null +++ b/plugins/ai/perplexity/perplexity.go @@ -0,0 +1,222 @@ +package perplexity + +import ( + "context" + "fmt" + "os" + "sync" // Added sync package + + "github.com/danielmiessler/fabric/common" + "github.com/danielmiessler/fabric/plugins" + perplexity "github.com/sgaunet/perplexity-go/v2" + + goopenai "github.com/sashabaranov/go-openai" +) + +const ( + providerName = "Perplexity" +) + +var models = []string{ + "r1-1776", "sonar", "sonar-pro", "sonar-reasoning", "sonar-reasoning-pro", +} + +type Client struct { + *plugins.PluginBase + APIKey *plugins.SetupQuestion + client *perplexity.Client +} + +func NewClient() *Client { + c := &Client{} + c.PluginBase = &plugins.PluginBase{ + Name: providerName, + EnvNamePrefix: plugins.BuildEnvVariablePrefix(providerName), + ConfigureCustom: c.Configure, // Assign the Configure method + } + c.APIKey = c.AddSetupQuestion("API_KEY", true) + return c +} + +func (c *Client) Configure() error { + // The PluginBase.Configure() is called by the framework if needed. + // We only need to handle specific logic for this plugin. + if c.APIKey.Value == "" { + // Attempt to get from environment variable if not set by user during setup + envKey := c.EnvNamePrefix + "API_KEY" + apiKeyFromEnv := os.Getenv(envKey) + if apiKeyFromEnv != "" { + c.APIKey.Value = apiKeyFromEnv + } else { + return fmt.Errorf("%s API key not configured. Please set the %s environment variable or run 'fabric --setup %s'", providerName, envKey, providerName) + } + } + c.client = perplexity.NewClient(c.APIKey.Value) + return nil +} + +func (c *Client) ListModels() ([]string, error) { + // Perplexity API does not have a ListModels endpoint. + // We return a predefined list. + return models, nil +} + +func (c *Client) Send(ctx context.Context, msgs []*goopenai.ChatCompletionMessage, opts *common.ChatOptions) (string, error) { + if c.client == nil { + if err := c.Configure(); err != nil { + return "", fmt.Errorf("failed to configure Perplexity client: %w", err) + } + } + + var perplexityMessages []perplexity.Message + for _, msg := range msgs { + perplexityMessages = append(perplexityMessages, perplexity.Message{ + Role: msg.Role, + Content: msg.Content, + }) + } + + requestOptions := []perplexity.CompletionRequestOption{ + perplexity.WithModel(opts.Model), + perplexity.WithMessages(perplexityMessages), + } + if opts.MaxTokens > 0 { + requestOptions = append(requestOptions, perplexity.WithMaxTokens(opts.MaxTokens)) + } + if opts.Temperature > 0 { // Perplexity default is 1.0, only set if user specifies + requestOptions = append(requestOptions, perplexity.WithTemperature(opts.Temperature)) + } + if opts.TopP > 0 { // Perplexity default is not specified, typically 1.0 + requestOptions = append(requestOptions, perplexity.WithTopP(opts.TopP)) + } + if opts.PresencePenalty != 0 { + // Corrected: Pass float64 directly + requestOptions = append(requestOptions, perplexity.WithPresencePenalty(opts.PresencePenalty)) + } + if opts.FrequencyPenalty != 0 { + // Corrected: Pass float64 directly + requestOptions = append(requestOptions, perplexity.WithFrequencyPenalty(opts.FrequencyPenalty)) + } + + request := perplexity.NewCompletionRequest(requestOptions...) + + // Corrected: Use SendCompletionRequest method from perplexity-go library + resp, err := c.client.SendCompletionRequest(request) // Pass request directly + if err != nil { + return "", fmt.Errorf("perplexity API request failed: %w", err) // Corrected capitalization + } + + return resp.GetLastContent(), nil +} + +func (c *Client) SendStream(msgs []*goopenai.ChatCompletionMessage, opts *common.ChatOptions, channel chan string) error { + if c.client == nil { + if err := c.Configure(); err != nil { + close(channel) // Ensure channel is closed on error + return fmt.Errorf("failed to configure Perplexity client: %w", err) + } + } + + var perplexityMessages []perplexity.Message + for _, msg := range msgs { + perplexityMessages = append(perplexityMessages, perplexity.Message{ + Role: msg.Role, + Content: msg.Content, + }) + } + + requestOptions := []perplexity.CompletionRequestOption{ + perplexity.WithModel(opts.Model), + perplexity.WithMessages(perplexityMessages), + perplexity.WithStream(true), // Enable streaming + } + + if opts.MaxTokens > 0 { + requestOptions = append(requestOptions, perplexity.WithMaxTokens(opts.MaxTokens)) + } + if opts.Temperature > 0 { + requestOptions = append(requestOptions, perplexity.WithTemperature(opts.Temperature)) + } + if opts.TopP > 0 { + requestOptions = append(requestOptions, perplexity.WithTopP(opts.TopP)) + } + if opts.PresencePenalty != 0 { + // Corrected: Pass float64 directly + requestOptions = append(requestOptions, perplexity.WithPresencePenalty(opts.PresencePenalty)) + } + if opts.FrequencyPenalty != 0 { + // Corrected: Pass float64 directly + requestOptions = append(requestOptions, perplexity.WithFrequencyPenalty(opts.FrequencyPenalty)) + } + + request := perplexity.NewCompletionRequest(requestOptions...) + + responseChan := make(chan perplexity.CompletionResponse) + var wg sync.WaitGroup // Use sync.WaitGroup + wg.Add(1) + + go func() { + err := c.client.SendSSEHTTPRequest(&wg, request, responseChan) + if err != nil { + // Log error, can't send to string channel directly. + // Consider a mechanism to propagate this error if needed. + fmt.Fprintf(os.Stderr, "perplexity streaming error: %v\\n", err) // Corrected capitalization + // If the error occurs during stream setup, the channel might not have been closed by the receiver loop. + // However, closing it here might cause a panic if the receiver loop also tries to close it. + // close(channel) // Caution: Uncommenting this may cause panic, as channel is closed in the receiver goroutine. + } + }() + + go func() { + defer close(channel) // Ensure the output channel is closed when this goroutine finishes + for resp := range responseChan { + if len(resp.Choices) > 0 { + content := "" + // Corrected: Check Delta.Content and Message.Content directly for non-emptiness + // as Delta and Message are structs, not pointers, in perplexity.Choice + if resp.Choices[0].Delta.Content != "" { + content = resp.Choices[0].Delta.Content + } else if resp.Choices[0].Message.Content != "" { + content = resp.Choices[0].Message.Content + } + if content != "" { + channel <- content + } + } + } + }() + + return nil +} + +func (c *Client) NeedsRawMode(modelName string) bool { + return true +} + +// Setup is called by the fabric CLI framework to guide the user through configuration. +func (c *Client) Setup() error { + return c.PluginBase.Setup() +} + +// GetName returns the name of the plugin. +func (c *Client) GetName() string { + return c.PluginBase.Name +} + +// GetEnvNamePrefix returns the environment variable prefix for the plugin. +// Corrected: Receiver name +func (c *Client) GetEnvNamePrefix() string { + return c.PluginBase.EnvNamePrefix +} + +// AddSetupQuestion adds a setup question to the plugin. +// This is a helper method, usually called from NewClient. +func (c *Client) AddSetupQuestion(text string, isSensitive bool) *plugins.SetupQuestion { + return c.PluginBase.AddSetupQuestion(text, isSensitive) +} + +// GetSetupQuestions returns the setup questions for the plugin. +// Corrected: Return the slice of setup questions from PluginBase +func (c *Client) GetSetupQuestions() []*plugins.SetupQuestion { + return c.PluginBase.SetupQuestions +}