diff --git a/examples/basic/main.go b/examples/basic/main.go index eb76627..9505913 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -35,6 +35,7 @@ import ( "github.com/agentuity/llmproxy/providers/googleai" "github.com/agentuity/llmproxy/providers/groq" "github.com/agentuity/llmproxy/providers/openai" + "github.com/agentuity/llmproxy/providers/perplexity" "github.com/agentuity/llmproxy/providers/xai" "go.opentelemetry.io/otel/trace" ) @@ -112,6 +113,15 @@ func main() { logr.Info("Registered: x.AI") } + if apiKey := os.Getenv("PERPLEXITY_API_KEY"); apiKey != "" { + provider, err := perplexity.New(apiKey) + if err != nil { + log.Fatalf("failed to create perplexity provider: %v", err) + } + registry.Register(provider) + logr.Info("Registered: Perplexity") + } + if apiKey := os.Getenv("GOOGLE_AI_API_KEY"); apiKey != "" { provider, err := googleai.New(apiKey) if err != nil { @@ -166,7 +176,7 @@ func main() { openaiProvider, _ = registry.Get("groq") } if openaiProvider == nil { - for _, name := range []string{"anthropic", "fireworks", "xai", "googleai"} { + for _, name := range []string{"anthropic", "fireworks", "xai", "perplexity", "googleai"} { if p, ok := registry.Get(name); ok { openaiProvider = p break diff --git a/go.mod b/go.mod index beb0e1d..73bb9a3 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,9 @@ module github.com/agentuity/llmproxy go 1.26.2 +require go.opentelemetry.io/otel/trace v1.43.0 + require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect ) diff --git a/go.sum b/go.sum index 73922dc..86176c1 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,16 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/providers/perplexity/provider.go b/providers/perplexity/provider.go new file mode 100644 index 0000000..3e1e3b1 --- /dev/null +++ b/providers/perplexity/provider.go @@ -0,0 +1,26 @@ +package perplexity + +import ( + "github.com/agentuity/llmproxy" + "github.com/agentuity/llmproxy/providers/openai_compatible" +) + +type Provider struct { + *llmproxy.BaseProvider +} + +func New(apiKey string) (*Provider, error) { + resolver, err := NewResolver("https://api.perplexity.ai") + if err != nil { + return nil, err + } + + return &Provider{ + BaseProvider: llmproxy.NewBaseProvider("perplexity", + llmproxy.WithBodyParser(&openai_compatible.Parser{}), + llmproxy.WithRequestEnricher(openai_compatible.NewEnricher(apiKey)), + llmproxy.WithResponseExtractor(openai_compatible.NewExtractor()), + llmproxy.WithURLResolver(resolver), + ), + }, nil +} diff --git a/providers/perplexity/provider_test.go b/providers/perplexity/provider_test.go new file mode 100644 index 0000000..fb56772 --- /dev/null +++ b/providers/perplexity/provider_test.go @@ -0,0 +1,103 @@ +package perplexity + +import ( + "net/http/httptest" + "testing" + + "github.com/agentuity/llmproxy" +) + +func TestNew(t *testing.T) { + provider, err := New("test-api-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if provider.Name() != "perplexity" { + t.Errorf("Name = %q, want %q", provider.Name(), "perplexity") + } + if provider.BodyParser() == nil { + t.Error("BodyParser should not be nil") + } + if provider.RequestEnricher() == nil { + t.Error("RequestEnricher should not be nil") + } + if provider.ResponseExtractor() == nil { + t.Error("ResponseExtractor should not be nil") + } + if provider.URLResolver() == nil { + t.Error("URLResolver should not be nil") + } +} + +func TestResolver_PerplexityURL(t *testing.T) { + provider, err := New("test-api-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + resolver := provider.URLResolver() + meta := llmproxy.BodyMetadata{Model: "sonar"} + u, err := resolver.Resolve(meta) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := "https://api.perplexity.ai/v1/sonar" + if u.String() != expected { + t.Errorf("URL = %q, want %q", u.String(), expected) + } +} + +func TestResolver_InvalidURL(t *testing.T) { + _, err := NewResolver("://invalid-url") + if err == nil { + t.Fatal("expected error for invalid URL") + } +} + +func TestEnricher_SetsAuthorization(t *testing.T) { + provider, err := New("pplx-test-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + enricher := provider.RequestEnricher() + req := httptest.NewRequest("POST", "https://api.perplexity.ai/v1/sonar", nil) + + err = enricher.Enrich(req, llmproxy.BodyMetadata{}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if auth := req.Header.Get("Authorization"); auth != "Bearer pplx-test-key" { + t.Errorf("Authorization = %q, want %q", auth, "Bearer pplx-test-key") + } + if ct := req.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want %q", ct, "application/json") + } +} + +func TestNew_EmptyKey(t *testing.T) { + provider, err := New("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if provider.Name() != "perplexity" { + t.Errorf("Name = %q, want %q", provider.Name(), "perplexity") + } + + enricher := provider.RequestEnricher() + req := httptest.NewRequest("POST", "https://api.perplexity.ai/v1/sonar", nil) + req.Header.Set("Authorization", "Bearer incoming-token") + + err = enricher.Enrich(req, llmproxy.BodyMetadata{}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if auth := req.Header.Get("Authorization"); auth != "" { + t.Errorf("Authorization = %q, want empty (header should be deleted for empty key)", auth) + } +} diff --git a/providers/perplexity/resolver.go b/providers/perplexity/resolver.go new file mode 100644 index 0000000..5f5409e --- /dev/null +++ b/providers/perplexity/resolver.go @@ -0,0 +1,23 @@ +package perplexity + +import ( + "net/url" + + "github.com/agentuity/llmproxy" +) + +type Resolver struct { + BaseURL *url.URL +} + +func (r *Resolver) Resolve(meta llmproxy.BodyMetadata) (*url.URL, error) { + return r.BaseURL.JoinPath("v1", "sonar"), nil +} + +func NewResolver(baseURL string) (*Resolver, error) { + u, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + return &Resolver{BaseURL: u}, nil +}