From 9bfbba2e82fdba3e5ed6639c5aeaa1f1a587c4e9 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sun, 12 Apr 2026 20:05:06 -0500 Subject: [PATCH 1/3] feat: add Perplexity provider with OpenAI-compatible API support --- examples/basic/main.go | 12 +++- go.mod | 3 +- go.sum | 10 ++++ providers/perplexity/provider.go | 9 +++ providers/perplexity/provider_test.go | 83 +++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 providers/perplexity/provider.go create mode 100644 providers/perplexity/provider_test.go 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..c103592 --- /dev/null +++ b/providers/perplexity/provider.go @@ -0,0 +1,9 @@ +package perplexity + +import ( + "github.com/agentuity/llmproxy/providers/openai_compatible" +) + +func New(apiKey string) (*openai_compatible.Provider, error) { + return openai_compatible.New("perplexity", apiKey, "https://api.perplexity.ai") +} diff --git a/providers/perplexity/provider_test.go b/providers/perplexity/provider_test.go new file mode 100644 index 0000000..86d799f --- /dev/null +++ b/providers/perplexity/provider_test.go @@ -0,0 +1,83 @@ +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/chat/completions" + if u.String() != expected { + t.Errorf("URL = %q, want %q", u.String(), expected) + } +} + +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/chat/completions", 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") + } +} From eb6f04b3fe9c42fafa95d88b1260d38393e8e41c Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sun, 12 Apr 2026 20:07:36 -0500 Subject: [PATCH 2/3] fix: use /v1/sonar endpoint for Perplexity API --- providers/perplexity/provider.go | 21 +++++++++++++++++++-- providers/perplexity/provider_test.go | 11 +++++++++-- providers/perplexity/resolver.go | 23 +++++++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 providers/perplexity/resolver.go diff --git a/providers/perplexity/provider.go b/providers/perplexity/provider.go index c103592..3e1e3b1 100644 --- a/providers/perplexity/provider.go +++ b/providers/perplexity/provider.go @@ -1,9 +1,26 @@ package perplexity import ( + "github.com/agentuity/llmproxy" "github.com/agentuity/llmproxy/providers/openai_compatible" ) -func New(apiKey string) (*openai_compatible.Provider, error) { - return openai_compatible.New("perplexity", apiKey, "https://api.perplexity.ai") +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 index 86d799f..ec77cd6 100644 --- a/providers/perplexity/provider_test.go +++ b/providers/perplexity/provider_test.go @@ -43,12 +43,19 @@ func TestResolver_PerplexityURL(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - expected := "https://api.perplexity.ai/v1/chat/completions" + 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 { @@ -56,7 +63,7 @@ func TestEnricher_SetsAuthorization(t *testing.T) { } enricher := provider.RequestEnricher() - req := httptest.NewRequest("POST", "https://api.perplexity.ai/v1/chat/completions", nil) + req := httptest.NewRequest("POST", "https://api.perplexity.ai/v1/sonar", nil) err = enricher.Enrich(req, llmproxy.BodyMetadata{}, nil) if err != nil { 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 +} From 27fbb06d2a6fac4bea46d537432d2ef95e752f59 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sun, 12 Apr 2026 20:12:11 -0500 Subject: [PATCH 3/3] fix: extend TestNew_EmptyKey to verify no Authorization header sent --- providers/perplexity/provider_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/providers/perplexity/provider_test.go b/providers/perplexity/provider_test.go index ec77cd6..fb56772 100644 --- a/providers/perplexity/provider_test.go +++ b/providers/perplexity/provider_test.go @@ -87,4 +87,17 @@ func TestNew_EmptyKey(t *testing.T) { 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) + } }