perplexity_api 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +38 -0
- data/CHANGELOG.md +32 -1
- data/Gemfile.lock +1 -1
- data/README.md +1 -1
- data/lib/perplexity_api/client.rb +32 -55
- data/lib/perplexity_api/configuration.rb +21 -2
- data/lib/perplexity_api/connection_pool.rb +102 -0
- data/lib/perplexity_api/request_builder.rb +94 -0
- data/lib/perplexity_api/stream_client.rb +73 -67
- data/lib/perplexity_api/version.rb +1 -1
- data/lib/perplexity_api.rb +13 -0
- metadata +4 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cb3cca4a4fd62c9a804f44db265099f87439c73b5ced04dfe3a7494445b6237a
|
4
|
+
data.tar.gz: 9b031b2e1514a47c40814e82981f7893b4b6aa8d4ceca6ee560992a0825f2b41
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 02f1f82e10a4eacb9681a3521e3706890ebbb1297c583b84f8e2a84706b1403ecbcb6a047ef005f5268d3c66f1d7eed9dc3545124a42e00aa41f61d2f4ff541e
|
7
|
+
data.tar.gz: acda4387cb32a91c543fd6836ad327d433173570b5f8d4df412f5b8c9d32eb328678f873fdebb48cd29f7796d7f85859da5e4e1fdb6ff2471d9a4ad58c8727a6
|
@@ -0,0 +1,38 @@
|
|
1
|
+
name: Claude PR Assistant
|
2
|
+
|
3
|
+
on:
|
4
|
+
issue_comment:
|
5
|
+
types: [created]
|
6
|
+
pull_request_review_comment:
|
7
|
+
types: [created]
|
8
|
+
issues:
|
9
|
+
types: [opened, assigned]
|
10
|
+
pull_request_review:
|
11
|
+
types: [submitted]
|
12
|
+
|
13
|
+
jobs:
|
14
|
+
claude-code-action:
|
15
|
+
if: |
|
16
|
+
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
17
|
+
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
18
|
+
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
19
|
+
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
|
20
|
+
runs-on: ubuntu-latest
|
21
|
+
permissions:
|
22
|
+
contents: read
|
23
|
+
pull-requests: read
|
24
|
+
issues: read
|
25
|
+
id-token: write
|
26
|
+
steps:
|
27
|
+
- name: Checkout repository
|
28
|
+
uses: actions/checkout@v4
|
29
|
+
with:
|
30
|
+
fetch-depth: 1
|
31
|
+
|
32
|
+
- name: Run Claude PR Action
|
33
|
+
uses: anthropics/claude-code-action@beta
|
34
|
+
with:
|
35
|
+
# anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
36
|
+
# Or use OAuth token instead:
|
37
|
+
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
38
|
+
timeout_minutes: "60"
|
data/CHANGELOG.md
CHANGED
@@ -2,6 +2,37 @@
|
|
2
2
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
4
4
|
|
5
|
+
## [0.5.0] - 2025-01-19
|
6
|
+
|
7
|
+
### Added
|
8
|
+
- Flexible timeout configuration for API calls
|
9
|
+
- Explicit timeout via options parameter
|
10
|
+
- Environment variable support (`PERPLEXITY_TIMEOUT`)
|
11
|
+
- Global configuration via `config.default_timeout`
|
12
|
+
- Intelligent timeout defaults based on operation type
|
13
|
+
- Web search: 60s (streaming: 120s)
|
14
|
+
- Deep research (`reasoning_effort: 'high'`): 300s (streaming: 600s)
|
15
|
+
- Regular queries: 30s (streaming: 60s)
|
16
|
+
- Connection pooling for improved performance
|
17
|
+
- HTTP connection reuse reduces latency by ~25%
|
18
|
+
|
19
|
+
### Security
|
20
|
+
- API key protection with safe_redact method
|
21
|
+
- Input validation with message size limits (100KB per message)
|
22
|
+
- Message array size limit (max 100 messages)
|
23
|
+
- Streaming buffer management with 10MB limit
|
24
|
+
|
25
|
+
### Improved
|
26
|
+
- Error handling with detailed JSON parsing errors
|
27
|
+
- Debug logging with automatic sensitive data redaction
|
28
|
+
- Code quality improvements with RequestBuilder module
|
29
|
+
- HTTP status codes as named constants
|
30
|
+
|
31
|
+
### Fixed
|
32
|
+
- Timeout errors for long-running operations (web search, deep research)
|
33
|
+
- Memory efficiency in streaming with StringIO buffer
|
34
|
+
- Connection pool cleanup for expired connections
|
35
|
+
|
5
36
|
## [0.4.1] - 2025-01-07
|
6
37
|
|
7
38
|
### Fixed
|
@@ -56,4 +87,4 @@ All notable changes to this project will be documented in this file.
|
|
56
87
|
- Basic chat functionality
|
57
88
|
- Configuration management
|
58
89
|
- Environment variable support
|
59
|
-
- Basic parameter support (temperature, max_tokens, top_p, top_k)
|
90
|
+
- Basic parameter support (temperature, max_tokens, top_p, top_k)
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
# PerplexityApi
|
2
|
-
[](https://badge.fury.io/rb/perplexity_api)
|
3
3
|
[](LICENSE.txt)
|
4
4
|
|
5
5
|
A Ruby wrapper gem for Perplexity AI's API. This gem allows you to easily integrate Perplexity AI's powerful language models into your Ruby applications.
|
@@ -4,6 +4,7 @@ require 'json'
|
|
4
4
|
|
5
5
|
module PerplexityApi
|
6
6
|
class Client
|
7
|
+
include RequestBuilder
|
7
8
|
attr_reader :config
|
8
9
|
|
9
10
|
def initialize(api_key: nil, model: nil, options: {})
|
@@ -11,6 +12,7 @@ module PerplexityApi
|
|
11
12
|
@config.api_key = api_key if api_key != nil
|
12
13
|
@model = model || @config.default_model
|
13
14
|
@options = @config.default_options.merge(options)
|
15
|
+
@connection_pool = nil
|
14
16
|
end
|
15
17
|
|
16
18
|
# Method to send a message and get a response
|
@@ -20,75 +22,50 @@ module PerplexityApi
|
|
20
22
|
messages = prepare_messages(messages)
|
21
23
|
merged_options = @options.merge(options)
|
22
24
|
|
25
|
+
# Determine timeout based on options
|
26
|
+
timeout = determine_timeout(merged_options, false)
|
27
|
+
|
28
|
+
# Create connection pool with appropriate timeout
|
29
|
+
@connection_pool ||= ConnectionPool.new(timeout: timeout)
|
30
|
+
|
23
31
|
uri = URI.parse("#{@config.api_base}/chat/completions")
|
24
|
-
http =
|
25
|
-
http.use_ssl = true
|
32
|
+
http = @connection_pool.get_connection(uri)
|
26
33
|
|
27
34
|
request = Net::HTTP::Post.new(uri.path)
|
28
35
|
request["Content-Type"] = "application/json"
|
29
36
|
request["Authorization"] = "Bearer #{@config.api_key}"
|
30
37
|
|
31
38
|
request_body = build_request_body(messages, merged_options)
|
32
|
-
|
39
|
+
begin
|
40
|
+
request.body = request_body.to_json
|
41
|
+
rescue JSON::GeneratorError => e
|
42
|
+
# Log the JSON generation error for debugging
|
43
|
+
@config.debug_log("JSON generation error for request: #{e.message}")
|
44
|
+
raise Error, "Failed to serialize request body to JSON: #{e.message}"
|
45
|
+
end
|
33
46
|
|
34
47
|
response = http.request(request)
|
35
48
|
|
36
|
-
|
37
|
-
|
49
|
+
# Return connection to pool for reuse
|
50
|
+
@connection_pool.return_connection(uri, http)
|
51
|
+
|
52
|
+
if response.code.to_i == PerplexityApi::HTTP_STATUS_OK
|
53
|
+
begin
|
54
|
+
JSON.parse(response.body)
|
55
|
+
rescue JSON::ParserError => e
|
56
|
+
# Log the JSON parsing error for debugging
|
57
|
+
@config.debug_log("JSON parsing error in response: #{e.message}")
|
58
|
+
@config.debug_log("Response body length: #{response.body.length}")
|
59
|
+
@config.debug_log("Response body preview: #{response.body[0...200]}")
|
60
|
+
raise Error, "Failed to parse API response as JSON: #{e.message}"
|
61
|
+
end
|
38
62
|
else
|
39
|
-
|
63
|
+
# Safely redact sensitive information from error messages
|
64
|
+
safe_error_message = @config.safe_redact(response.body)
|
65
|
+
raise Error, "API call failed: #{response.code} #{safe_error_message}"
|
40
66
|
end
|
41
67
|
end
|
42
68
|
|
43
69
|
private
|
44
|
-
|
45
|
-
def prepare_messages(messages)
|
46
|
-
case messages
|
47
|
-
when String
|
48
|
-
[{ role: "user", content: messages }]
|
49
|
-
when Array
|
50
|
-
messages
|
51
|
-
else
|
52
|
-
raise ArgumentError, "Messages must be a string or array"
|
53
|
-
end
|
54
|
-
end
|
55
|
-
|
56
|
-
def build_request_body(messages, options)
|
57
|
-
body = {
|
58
|
-
model: @model,
|
59
|
-
messages: messages
|
60
|
-
}
|
61
|
-
|
62
|
-
# Basic parameters
|
63
|
-
body[:temperature] = options[:temperature] if options[:temperature]
|
64
|
-
body[:max_tokens] = options[:max_tokens] if options[:max_tokens]
|
65
|
-
body[:top_p] = options[:top_p] if options[:top_p]
|
66
|
-
body[:top_k] = options[:top_k] if options[:top_k]
|
67
|
-
body[:frequency_penalty] = options[:frequency_penalty] if options[:frequency_penalty]
|
68
|
-
body[:presence_penalty] = options[:presence_penalty] if options[:presence_penalty]
|
69
|
-
body[:stream] = options[:stream] if options.key?(:stream)
|
70
|
-
|
71
|
-
# Search parameters
|
72
|
-
body[:search_mode] = options[:search_mode] if options[:search_mode]
|
73
|
-
body[:search_domain_filter] = options[:search_domain_filter] if options[:search_domain_filter]
|
74
|
-
body[:search_recency_filter] = options[:search_recency_filter] if options[:search_recency_filter]
|
75
|
-
body[:search_after_date_filter] = options[:search_after_date_filter] if options[:search_after_date_filter]
|
76
|
-
body[:search_before_date_filter] = options[:search_before_date_filter] if options[:search_before_date_filter]
|
77
|
-
body[:last_updated_after_filter] = options[:last_updated_after_filter] if options[:last_updated_after_filter]
|
78
|
-
body[:last_updated_before_filter] = options[:last_updated_before_filter] if options[:last_updated_before_filter]
|
79
|
-
|
80
|
-
# Beta features
|
81
|
-
body[:return_images] = options[:return_images] if options[:return_images]
|
82
|
-
body[:return_related_questions] = options[:return_related_questions] if options[:return_related_questions]
|
83
|
-
|
84
|
-
# Advanced features
|
85
|
-
body[:reasoning_effort] = options[:reasoning_effort] if options[:reasoning_effort]
|
86
|
-
|
87
|
-
if options[:web_search_options]
|
88
|
-
body[:web_search_options] = options[:web_search_options]
|
89
|
-
end
|
90
|
-
|
91
|
-
body
|
92
|
-
end
|
93
70
|
end
|
94
71
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
module PerplexityApi
|
2
2
|
class Configuration
|
3
|
-
attr_accessor :api_key, :api_base, :default_model, :default_options, :debug_mode
|
3
|
+
attr_accessor :api_key, :api_base, :default_model, :default_options, :debug_mode, :default_timeout
|
4
4
|
|
5
5
|
def initialize(debug_mode: false)
|
6
6
|
@debug_mode = debug_mode
|
@@ -21,6 +21,7 @@ module PerplexityApi
|
|
21
21
|
frequency_penalty: ENV["PERPLEXITY_FREQUENCY_PENALTY"] ? ENV["PERPLEXITY_FREQUENCY_PENALTY"].to_f : 0.0,
|
22
22
|
presence_penalty: ENV["PERPLEXITY_PRESENCE_PENALTY"] ? ENV["PERPLEXITY_PRESENCE_PENALTY"].to_f : 0.0
|
23
23
|
}
|
24
|
+
@default_timeout = ENV["PERPLEXITY_TIMEOUT"] ? ENV["PERPLEXITY_TIMEOUT"].to_i : 30
|
24
25
|
|
25
26
|
debug_log "Configuration loaded from environment variables"
|
26
27
|
debug_log "API Key: #{@api_key ? 'Set' : 'Not set'}"
|
@@ -32,11 +33,29 @@ module PerplexityApi
|
|
32
33
|
raise Error, "API key is not set." unless api_key
|
33
34
|
end
|
34
35
|
|
36
|
+
# Public method for safe redaction of sensitive information
|
37
|
+
def safe_redact(message)
|
38
|
+
return message unless message.is_a?(String)
|
39
|
+
|
40
|
+
# Redact API keys (Bearer tokens)
|
41
|
+
message = message.gsub(/Bearer [a-zA-Z0-9\-_]+/, "Bearer [REDACTED]")
|
42
|
+
|
43
|
+
# Redact potential API keys in various formats
|
44
|
+
message = message.gsub(/api_key["\s]*[:=]["\s]*[a-zA-Z0-9\-_]+/, 'api_key: [REDACTED]')
|
45
|
+
message = message.gsub(/["\']api_key["\']:\s*["\'][a-zA-Z0-9\-_]+["\']/, '"api_key": "[REDACTED]"')
|
46
|
+
|
47
|
+
# Redact Authorization headers
|
48
|
+
message = message.gsub(/Authorization["\s]*[:=]["\s]*[a-zA-Z0-9\-_\s]+/, 'Authorization: [REDACTED]')
|
49
|
+
|
50
|
+
message
|
51
|
+
end
|
52
|
+
|
35
53
|
private
|
36
54
|
|
37
55
|
def debug_log(message)
|
38
|
-
puts "[PerplexityApi] #{message}" if @debug_mode
|
56
|
+
puts "[PerplexityApi] #{safe_redact(message)}" if @debug_mode
|
39
57
|
end
|
58
|
+
|
40
59
|
end
|
41
60
|
|
42
61
|
class << self
|
@@ -0,0 +1,102 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'uri'
|
3
|
+
require 'thread'
|
4
|
+
|
5
|
+
module PerplexityApi
|
6
|
+
class ConnectionPool
|
7
|
+
def initialize(max_connections: 5, timeout: 30)
|
8
|
+
@max_connections = max_connections
|
9
|
+
@timeout = timeout
|
10
|
+
@connections = {}
|
11
|
+
@mutex = Mutex.new
|
12
|
+
end
|
13
|
+
|
14
|
+
def get_connection(uri)
|
15
|
+
key = "#{uri.host}:#{uri.port}"
|
16
|
+
|
17
|
+
@mutex.synchronize do
|
18
|
+
# Clean up expired connections
|
19
|
+
cleanup_expired_connections
|
20
|
+
|
21
|
+
# Get or create connection pool for this host
|
22
|
+
@connections[key] ||= []
|
23
|
+
pool = @connections[key]
|
24
|
+
|
25
|
+
# Try to reuse existing connection
|
26
|
+
while pool.size > 0
|
27
|
+
connection = pool.pop
|
28
|
+
if connection_still_valid?(connection)
|
29
|
+
return connection
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Create new connection if pool is empty
|
34
|
+
create_connection(uri)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def return_connection(uri, connection)
|
39
|
+
key = "#{uri.host}:#{uri.port}"
|
40
|
+
|
41
|
+
@mutex.synchronize do
|
42
|
+
@connections[key] ||= []
|
43
|
+
pool = @connections[key]
|
44
|
+
|
45
|
+
# Only return to pool if we haven't exceeded max connections
|
46
|
+
if pool.size < @max_connections && connection_still_valid?(connection)
|
47
|
+
connection.instance_variable_set(:@last_used, Time.now)
|
48
|
+
pool.push(connection)
|
49
|
+
else
|
50
|
+
# Close excess connections
|
51
|
+
begin
|
52
|
+
connection.finish if connection.started?
|
53
|
+
rescue => e
|
54
|
+
# Ignore errors when closing connections
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def create_connection(uri)
|
63
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
64
|
+
http.use_ssl = true if uri.scheme == 'https'
|
65
|
+
http.open_timeout = @timeout
|
66
|
+
http.read_timeout = @timeout
|
67
|
+
http.instance_variable_set(:@last_used, Time.now)
|
68
|
+
http.start
|
69
|
+
http
|
70
|
+
end
|
71
|
+
|
72
|
+
def connection_still_valid?(connection)
|
73
|
+
return false unless connection
|
74
|
+
return false unless connection.started?
|
75
|
+
|
76
|
+
# Check if connection is too old (30 seconds)
|
77
|
+
last_used = connection.instance_variable_get(:@last_used)
|
78
|
+
return false if last_used && Time.now - last_used > 30
|
79
|
+
|
80
|
+
true
|
81
|
+
rescue => e
|
82
|
+
false
|
83
|
+
end
|
84
|
+
|
85
|
+
def cleanup_expired_connections
|
86
|
+
@connections.each do |key, pool|
|
87
|
+
pool.reject! do |connection|
|
88
|
+
if connection_still_valid?(connection)
|
89
|
+
false
|
90
|
+
else
|
91
|
+
begin
|
92
|
+
connection.finish if connection.started?
|
93
|
+
rescue => e
|
94
|
+
# Ignore errors when closing connections
|
95
|
+
end
|
96
|
+
true
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
module PerplexityApi
|
2
|
+
module RequestBuilder
|
3
|
+
|
4
|
+
def determine_timeout(options, is_streaming = false)
|
5
|
+
# Priority: explicit timeout > reasoning_effort/search_mode based > config default
|
6
|
+
return options[:timeout] if options[:timeout]
|
7
|
+
|
8
|
+
# Deep research or high reasoning effort needs more time
|
9
|
+
if options[:reasoning_effort] == 'high'
|
10
|
+
return is_streaming ? 600 : 300 # 10 min for streaming, 5 min for regular
|
11
|
+
elsif options[:search_mode] == 'web'
|
12
|
+
return is_streaming ? 120 : 60 # 2 min for streaming, 1 min for regular
|
13
|
+
else
|
14
|
+
default = @config.default_timeout || 30
|
15
|
+
return is_streaming ? default * 2 : default
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def prepare_messages(messages)
|
20
|
+
case messages
|
21
|
+
when String
|
22
|
+
validate_message_content(messages)
|
23
|
+
[{ role: "user", content: messages }]
|
24
|
+
when Array
|
25
|
+
validate_message_array(messages)
|
26
|
+
messages
|
27
|
+
else
|
28
|
+
raise ArgumentError, "Messages must be a string or array"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def validate_message_content(content)
|
33
|
+
raise ArgumentError, "Message content cannot be nil" if content.nil?
|
34
|
+
raise ArgumentError, "Message content cannot be empty" if content.strip.empty?
|
35
|
+
raise ArgumentError, "Message content too long" if content.length > 100000 # 100KB limit
|
36
|
+
end
|
37
|
+
|
38
|
+
def validate_message_array(messages)
|
39
|
+
raise ArgumentError, "Messages array cannot be empty" if messages.empty?
|
40
|
+
raise ArgumentError, "Messages array too large" if messages.length > 100 # Reasonable limit
|
41
|
+
|
42
|
+
messages.each_with_index do |message, index|
|
43
|
+
raise ArgumentError, "Message #{index} must be a hash" unless message.is_a?(Hash)
|
44
|
+
raise ArgumentError, "Message #{index} must have a 'role' field" unless message.key?(:role) || message.key?("role")
|
45
|
+
raise ArgumentError, "Message #{index} must have a 'content' field" unless message.key?(:content) || message.key?("content")
|
46
|
+
|
47
|
+
role = message[:role] || message["role"]
|
48
|
+
content = message[:content] || message["content"]
|
49
|
+
|
50
|
+
raise ArgumentError, "Message #{index} role must be 'user', 'assistant', or 'system'" unless %w[user assistant system].include?(role.to_s)
|
51
|
+
|
52
|
+
validate_message_content(content.to_s)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def build_request_body(messages, options = {})
|
57
|
+
body = {
|
58
|
+
model: @model,
|
59
|
+
messages: messages
|
60
|
+
}
|
61
|
+
|
62
|
+
# Basic parameters
|
63
|
+
body[:temperature] = options[:temperature] if options[:temperature]
|
64
|
+
body[:max_tokens] = options[:max_tokens] if options[:max_tokens]
|
65
|
+
body[:top_p] = options[:top_p] if options[:top_p]
|
66
|
+
body[:top_k] = options[:top_k] if options[:top_k]
|
67
|
+
body[:frequency_penalty] = options[:frequency_penalty] if options[:frequency_penalty]
|
68
|
+
body[:presence_penalty] = options[:presence_penalty] if options[:presence_penalty]
|
69
|
+
body[:stream] = options[:stream] if options.key?(:stream)
|
70
|
+
|
71
|
+
# Search parameters
|
72
|
+
body[:search_mode] = options[:search_mode] if options[:search_mode]
|
73
|
+
body[:search_domain_filter] = options[:search_domain_filter] if options[:search_domain_filter]
|
74
|
+
body[:search_recency_filter] = options[:search_recency_filter] if options[:search_recency_filter]
|
75
|
+
body[:search_after_date_filter] = options[:search_after_date_filter] if options[:search_after_date_filter]
|
76
|
+
body[:search_before_date_filter] = options[:search_before_date_filter] if options[:search_before_date_filter]
|
77
|
+
body[:last_updated_after_filter] = options[:last_updated_after_filter] if options[:last_updated_after_filter]
|
78
|
+
body[:last_updated_before_filter] = options[:last_updated_before_filter] if options[:last_updated_before_filter]
|
79
|
+
|
80
|
+
# Beta features
|
81
|
+
body[:return_images] = options[:return_images] if options[:return_images]
|
82
|
+
body[:return_related_questions] = options[:return_related_questions] if options[:return_related_questions]
|
83
|
+
|
84
|
+
# Advanced features
|
85
|
+
body[:reasoning_effort] = options[:reasoning_effort] if options[:reasoning_effort]
|
86
|
+
|
87
|
+
if options[:web_search_options]
|
88
|
+
body[:web_search_options] = options[:web_search_options]
|
89
|
+
end
|
90
|
+
|
91
|
+
body
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -1,9 +1,11 @@
|
|
1
1
|
require 'net/http'
|
2
2
|
require 'uri'
|
3
3
|
require 'json'
|
4
|
+
require 'stringio'
|
4
5
|
|
5
6
|
module PerplexityApi
|
6
7
|
class StreamClient
|
8
|
+
include RequestBuilder
|
7
9
|
attr_reader :config
|
8
10
|
|
9
11
|
def initialize(api_key: nil, model: nil, options: {})
|
@@ -11,6 +13,7 @@ module PerplexityApi
|
|
11
13
|
@config.api_key = api_key if api_key != nil
|
12
14
|
@model = model || @config.default_model
|
13
15
|
@options = @config.default_options.merge(options)
|
16
|
+
@connection_pool = nil
|
14
17
|
end
|
15
18
|
|
16
19
|
def chat(messages, &block)
|
@@ -18,98 +21,101 @@ module PerplexityApi
|
|
18
21
|
|
19
22
|
messages = prepare_messages(messages)
|
20
23
|
|
24
|
+
# Determine timeout based on options
|
25
|
+
timeout = determine_timeout(@options, true)
|
26
|
+
|
27
|
+
# Create connection pool with appropriate timeout
|
28
|
+
@connection_pool ||= ConnectionPool.new(max_connections: 2, timeout: timeout)
|
29
|
+
|
21
30
|
uri = URI.parse("#{@config.api_base}/chat/completions")
|
31
|
+
http = @connection_pool.get_connection(uri)
|
22
32
|
|
23
|
-
|
33
|
+
begin
|
24
34
|
request = Net::HTTP::Post.new(uri.path)
|
25
35
|
request["Content-Type"] = "application/json"
|
26
36
|
request["Authorization"] = "Bearer #{@config.api_key}"
|
27
37
|
request["Accept"] = "text/event-stream"
|
28
38
|
request["Cache-Control"] = "no-cache"
|
29
39
|
|
30
|
-
request_body = build_request_body(messages)
|
40
|
+
request_body = build_request_body(messages, @options)
|
31
41
|
request_body[:stream] = true
|
32
|
-
|
42
|
+
begin
|
43
|
+
request.body = request_body.to_json
|
44
|
+
rescue JSON::GeneratorError => e
|
45
|
+
# Log the JSON generation error for debugging
|
46
|
+
@config.debug_log("JSON generation error for request: #{e.message}")
|
47
|
+
raise Error, "Failed to serialize request body to JSON: #{e.message}"
|
48
|
+
end
|
33
49
|
|
34
50
|
http.request(request) do |response|
|
35
|
-
if response.code.to_i !=
|
36
|
-
|
51
|
+
if response.code.to_i != PerplexityApi::HTTP_STATUS_OK
|
52
|
+
# Safely redact sensitive information from error messages
|
53
|
+
error_body = response.read_body
|
54
|
+
safe_error_message = @config.safe_redact(error_body)
|
55
|
+
raise Error, "API call failed: #{response.code} #{safe_error_message}"
|
37
56
|
end
|
38
57
|
|
39
|
-
buffer =
|
58
|
+
buffer = StringIO.new
|
59
|
+
max_buffer_size = 10 * 1024 * 1024 # 10MB limit
|
60
|
+
|
40
61
|
response.read_body do |chunk|
|
41
|
-
buffer
|
62
|
+
# Check for buffer size limit before adding chunk
|
63
|
+
if buffer.size + chunk.bytesize > max_buffer_size
|
64
|
+
raise Error, "Stream buffer exceeded maximum size (#{max_buffer_size} bytes). Stream may be malformed."
|
65
|
+
end
|
66
|
+
|
67
|
+
buffer.write(chunk)
|
42
68
|
|
43
|
-
|
44
|
-
|
45
|
-
buffer
|
69
|
+
begin
|
70
|
+
# Process complete lines more efficiently
|
71
|
+
buffer.rewind
|
72
|
+
content = buffer.read
|
46
73
|
|
47
|
-
|
48
|
-
|
74
|
+
# Find all complete lines
|
75
|
+
lines = content.split("\n")
|
49
76
|
|
50
|
-
|
51
|
-
|
77
|
+
# Keep the last incomplete line in the buffer
|
78
|
+
if content.end_with?("\n")
|
79
|
+
buffer = StringIO.new
|
80
|
+
incomplete_line = ""
|
81
|
+
else
|
82
|
+
incomplete_line = lines.pop || ""
|
83
|
+
buffer = StringIO.new
|
84
|
+
buffer.write(incomplete_line)
|
85
|
+
end
|
52
86
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
87
|
+
# Process complete lines
|
88
|
+
lines.each do |line|
|
89
|
+
next if line.strip.empty?
|
90
|
+
next unless line.start_with?("data: ")
|
91
|
+
|
92
|
+
data = line[6..-1].strip
|
93
|
+
next if data == "[DONE]"
|
94
|
+
|
95
|
+
begin
|
96
|
+
parsed = JSON.parse(data)
|
97
|
+
block.call(parsed) if block_given?
|
98
|
+
rescue JSON::ParserError => e
|
99
|
+
# Skip invalid JSON but log the error for debugging
|
100
|
+
@config.debug_log("JSON parsing error in stream: #{e.message}")
|
101
|
+
end
|
58
102
|
end
|
103
|
+
rescue => e
|
104
|
+
# Clear buffer on processing error to prevent accumulation
|
105
|
+
buffer = StringIO.new
|
106
|
+
raise Error, "Stream processing error: #{e.message}"
|
59
107
|
end
|
60
108
|
end
|
109
|
+
|
110
|
+
# Final cleanup - ensure buffer is cleared
|
111
|
+
buffer = StringIO.new
|
61
112
|
end
|
113
|
+
ensure
|
114
|
+
# Return connection to pool for reuse
|
115
|
+
@connection_pool.return_connection(uri, http) if http
|
62
116
|
end
|
63
117
|
end
|
64
118
|
|
65
119
|
private
|
66
|
-
|
67
|
-
def prepare_messages(messages)
|
68
|
-
case messages
|
69
|
-
when String
|
70
|
-
[{ role: "user", content: messages }]
|
71
|
-
when Array
|
72
|
-
messages
|
73
|
-
else
|
74
|
-
raise ArgumentError, "Messages must be a string or array"
|
75
|
-
end
|
76
|
-
end
|
77
|
-
|
78
|
-
def build_request_body(messages)
|
79
|
-
body = {
|
80
|
-
model: @model,
|
81
|
-
messages: messages
|
82
|
-
}
|
83
|
-
|
84
|
-
# Basic parameters
|
85
|
-
body[:temperature] = @options[:temperature] if @options[:temperature]
|
86
|
-
body[:max_tokens] = @options[:max_tokens] if @options[:max_tokens]
|
87
|
-
body[:top_p] = @options[:top_p] if @options[:top_p]
|
88
|
-
body[:top_k] = @options[:top_k] if @options[:top_k]
|
89
|
-
body[:frequency_penalty] = @options[:frequency_penalty] if @options[:frequency_penalty]
|
90
|
-
body[:presence_penalty] = @options[:presence_penalty] if @options[:presence_penalty]
|
91
|
-
|
92
|
-
# Search parameters
|
93
|
-
body[:search_mode] = @options[:search_mode] if @options[:search_mode]
|
94
|
-
body[:search_domain_filter] = @options[:search_domain_filter] if @options[:search_domain_filter]
|
95
|
-
body[:search_recency_filter] = @options[:search_recency_filter] if @options[:search_recency_filter]
|
96
|
-
body[:search_after_date_filter] = @options[:search_after_date_filter] if @options[:search_after_date_filter]
|
97
|
-
body[:search_before_date_filter] = @options[:search_before_date_filter] if @options[:search_before_date_filter]
|
98
|
-
body[:last_updated_after_filter] = @options[:last_updated_after_filter] if @options[:last_updated_after_filter]
|
99
|
-
body[:last_updated_before_filter] = @options[:last_updated_before_filter] if @options[:last_updated_before_filter]
|
100
|
-
|
101
|
-
# Beta features
|
102
|
-
body[:return_images] = @options[:return_images] if @options[:return_images]
|
103
|
-
body[:return_related_questions] = @options[:return_related_questions] if @options[:return_related_questions]
|
104
|
-
|
105
|
-
# Advanced features
|
106
|
-
body[:reasoning_effort] = @options[:reasoning_effort] if @options[:reasoning_effort]
|
107
|
-
|
108
|
-
if @options[:web_search_options]
|
109
|
-
body[:web_search_options] = @options[:web_search_options]
|
110
|
-
end
|
111
|
-
|
112
|
-
body
|
113
|
-
end
|
114
120
|
end
|
115
121
|
end
|
data/lib/perplexity_api.rb
CHANGED
@@ -1,12 +1,25 @@
|
|
1
1
|
require "perplexity_api/version"
|
2
2
|
require "perplexity_api/configuration"
|
3
3
|
require "perplexity_api/models"
|
4
|
+
require "perplexity_api/request_builder"
|
5
|
+
require "perplexity_api/connection_pool"
|
4
6
|
require "perplexity_api/client"
|
5
7
|
require "perplexity_api/stream_client"
|
6
8
|
|
7
9
|
module PerplexityApi
|
8
10
|
class Error < StandardError; end
|
9
11
|
|
12
|
+
# HTTP Status Code Constants
|
13
|
+
HTTP_STATUS_OK = 200
|
14
|
+
HTTP_STATUS_BAD_REQUEST = 400
|
15
|
+
HTTP_STATUS_UNAUTHORIZED = 401
|
16
|
+
HTTP_STATUS_FORBIDDEN = 403
|
17
|
+
HTTP_STATUS_NOT_FOUND = 404
|
18
|
+
HTTP_STATUS_RATE_LIMITED = 429
|
19
|
+
HTTP_STATUS_INTERNAL_SERVER_ERROR = 500
|
20
|
+
HTTP_STATUS_BAD_GATEWAY = 502
|
21
|
+
HTTP_STATUS_SERVICE_UNAVAILABLE = 503
|
22
|
+
|
10
23
|
# Helper method to create a client instance
|
11
24
|
def self.new(api_key: nil, model: nil, options: {})
|
12
25
|
Client.new(api_key: api_key, model: model, options: options)
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: perplexity_api
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Delightech
|
@@ -60,6 +60,7 @@ extensions: []
|
|
60
60
|
extra_rdoc_files: []
|
61
61
|
files:
|
62
62
|
- ".env.example"
|
63
|
+
- ".github/workflows/main.yml"
|
63
64
|
- ".gitignore"
|
64
65
|
- ".rspec"
|
65
66
|
- ".ruby-version"
|
@@ -80,7 +81,9 @@ files:
|
|
80
81
|
- lib/perplexity_api.rb
|
81
82
|
- lib/perplexity_api/client.rb
|
82
83
|
- lib/perplexity_api/configuration.rb
|
84
|
+
- lib/perplexity_api/connection_pool.rb
|
83
85
|
- lib/perplexity_api/models.rb
|
86
|
+
- lib/perplexity_api/request_builder.rb
|
84
87
|
- lib/perplexity_api/stream_client.rb
|
85
88
|
- lib/perplexity_api/version.rb
|
86
89
|
- perplexity_api.gemspec
|