durable-llm 0.1.4 → 0.1.5

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.
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OpenRouter provider for accessing various language models through the OpenRouter API.
4
+
5
+ require 'faraday'
6
+ require 'json'
7
+ require 'event_stream_parser'
8
+ require 'durable/llm/errors'
9
+ require 'durable/llm/providers/base'
10
+
11
+ module Durable
12
+ module Llm
13
+ module Providers
14
+ # OpenRouter provider for accessing various language models through the OpenRouter API.
15
+ # Provides completion, embedding, and streaming capabilities with authentication handling,
16
+ # error management, and response normalization.
17
+ class OpenRouter < Durable::Llm::Providers::Base
18
+ BASE_URL = 'https://openrouter.ai/api/v1'
19
+
20
+ def default_api_key
21
+ begin
22
+ Durable::Llm.configuration.openrouter&.api_key
23
+ rescue NoMethodError
24
+ nil
25
+ end || ENV['OPENROUTER_API_KEY']
26
+ end
27
+
28
+ attr_accessor :api_key
29
+
30
+ def initialize(api_key: nil)
31
+ super()
32
+ @api_key = api_key || default_api_key
33
+ @conn = Faraday.new(url: BASE_URL) do |faraday|
34
+ faraday.request :json
35
+ faraday.response :json
36
+ faraday.adapter Faraday.default_adapter
37
+ end
38
+ end
39
+
40
+ def completion(options)
41
+ response = @conn.post('chat/completions') do |req|
42
+ req.headers['Authorization'] = "Bearer #{@api_key}"
43
+ req.body = options
44
+ end
45
+
46
+ handle_response(response)
47
+ end
48
+
49
+ def embedding(model:, input:, **options)
50
+ response = @conn.post('embeddings') do |req|
51
+ req.headers['Authorization'] = "Bearer #{@api_key}"
52
+ req.body = { model: model, input: input, **options }
53
+ end
54
+
55
+ handle_response(response, OpenRouterEmbeddingResponse)
56
+ end
57
+
58
+ def models
59
+ response = @conn.get('models') do |req|
60
+ req.headers['Authorization'] = "Bearer #{@api_key}"
61
+ end
62
+
63
+ handle_response(response).data.map { |model| model['id'] }
64
+ end
65
+
66
+ def self.stream?
67
+ true
68
+ end
69
+
70
+ def stream(options, &block)
71
+ options[:stream] = true
72
+ options['temperature'] = options['temperature'].to_f if options['temperature']
73
+
74
+ user_proc = proc do |chunk, _size, _total|
75
+ block.call(OpenRouterStreamResponse.new(chunk))
76
+ end
77
+
78
+ response = @conn.post('chat/completions') do |req|
79
+ req.headers['Authorization'] = "Bearer #{@api_key}"
80
+ req.headers['Accept'] = 'text/event-stream'
81
+ req.body = options
82
+ req.options.on_data = to_json_stream(user_proc: user_proc)
83
+ end
84
+
85
+ handle_response(response)
86
+ end
87
+
88
+ private
89
+
90
+ # CODE-FROM: ruby-openai @ https://github.com/alexrudall/ruby-openai/blob/main/lib/openai/http.rb
91
+ # MIT License: https://github.com/alexrudall/ruby-openai/blob/main/LICENSE.md
92
+ # Given a proc, returns an outer proc that can be used to iterate over a JSON stream of chunks.
93
+ # For each chunk, the inner user_proc is called giving it the JSON object. The JSON object could
94
+ # be a data object or an error object as described in the OpenAI API documentation.
95
+ #
96
+ # @param user_proc [Proc] The inner proc to call for each JSON object in the chunk.
97
+ # @return [Proc] An outer proc that iterates over a raw stream, converting it to JSON.
98
+ def to_json_stream(user_proc:)
99
+ parser = EventStreamParser::Parser.new
100
+
101
+ proc do |chunk, _bytes, env|
102
+ if env && env.status != 200
103
+ raise_error = Faraday::Response::RaiseError.new
104
+ raise_error.on_complete(env.merge(body: try_parse_json(chunk)))
105
+ end
106
+
107
+ parser.feed(chunk) do |_type, data|
108
+ user_proc.call(JSON.parse(data)) unless data == '[DONE]'
109
+ end
110
+ end
111
+ end
112
+
113
+ def try_parse_json(maybe_json)
114
+ JSON.parse(maybe_json)
115
+ rescue JSON::ParserError
116
+ maybe_json
117
+ end
118
+
119
+ # END-CODE-FROM
120
+
121
+ def handle_response(response, response_class = OpenRouterResponse)
122
+ case response.status
123
+ when 200..299
124
+ response_class.new(response.body)
125
+ when 401
126
+ raise Durable::Llm::AuthenticationError, parse_error_message(response)
127
+ when 429
128
+ raise Durable::Llm::RateLimitError, parse_error_message(response)
129
+ when 400..499
130
+ raise Durable::Llm::InvalidRequestError, parse_error_message(response)
131
+ when 500..599
132
+ raise Durable::Llm::ServerError, parse_error_message(response)
133
+ else
134
+ raise Durable::Llm::APIError, "Unexpected response code: #{response.status}"
135
+ end
136
+ end
137
+
138
+ def parse_error_message(response)
139
+ body = begin
140
+ JSON.parse(response.body)
141
+ rescue StandardError
142
+ nil
143
+ end
144
+ message = body&.dig('error', 'message') || response.body
145
+ "#{response.status} Error: #{message}"
146
+ end
147
+
148
+ # Response wrapper for OpenRouter API completion responses.
149
+ class OpenRouterResponse
150
+ attr_reader :raw_response
151
+
152
+ def initialize(response)
153
+ @raw_response = response
154
+ end
155
+
156
+ def choices
157
+ @raw_response['choices'].map { |choice| OpenRouterChoice.new(choice) }
158
+ end
159
+
160
+ def data
161
+ @raw_response['data']
162
+ end
163
+
164
+ def to_s
165
+ choices.map(&:to_s).join(' ')
166
+ end
167
+ end
168
+
169
+ # Choice wrapper for OpenRouter API responses.
170
+ class OpenRouterChoice
171
+ attr_reader :message, :finish_reason
172
+
173
+ def initialize(choice)
174
+ @message = OpenRouterMessage.new(choice['message'])
175
+ @finish_reason = choice['finish_reason']
176
+ end
177
+
178
+ def to_s
179
+ @message.to_s
180
+ end
181
+ end
182
+
183
+ # Message wrapper for OpenRouter API responses.
184
+ class OpenRouterMessage
185
+ attr_reader :role, :content
186
+
187
+ def initialize(message)
188
+ @role = message['role']
189
+ @content = message['content']
190
+ end
191
+
192
+ def to_s
193
+ @content
194
+ end
195
+ end
196
+
197
+ # Stream response wrapper for OpenRouter API streaming responses.
198
+ class OpenRouterStreamResponse
199
+ attr_reader :choices
200
+
201
+ def initialize(parsed)
202
+ @choices = OpenRouterStreamChoice.new(parsed['choices'])
203
+ end
204
+
205
+ def to_s
206
+ @choices.to_s
207
+ end
208
+ end
209
+
210
+ # Embedding response wrapper for OpenRouter API embedding responses.
211
+ class OpenRouterEmbeddingResponse
212
+ attr_reader :embedding
213
+
214
+ def initialize(data)
215
+ @embedding = data.dig('data', 0, 'embedding')
216
+ end
217
+
218
+ def to_a
219
+ @embedding
220
+ end
221
+ end
222
+
223
+ # Stream choice wrapper for OpenRouter API streaming responses.
224
+ class OpenRouterStreamChoice
225
+ attr_reader :delta, :finish_reason
226
+
227
+ def initialize(choice)
228
+ @choice = [choice].flatten.first
229
+ @delta = OpenRouterStreamDelta.new(@choice['delta'])
230
+ @finish_reason = @choice['finish_reason']
231
+ end
232
+
233
+ def to_s
234
+ @delta.to_s
235
+ end
236
+ end
237
+
238
+ # Stream delta wrapper for OpenRouter API streaming responses.
239
+ class OpenRouterStreamDelta
240
+ attr_reader :role, :content
241
+
242
+ def initialize(delta)
243
+ @role = delta['role']
244
+ @content = delta['content']
245
+ end
246
+
247
+ def to_s
248
+ @content || ''
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
255
+
256
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file implements the Perplexity provider for accessing Perplexity's language models through their API,
4
+ # providing completion, embedding, and streaming capabilities with authentication handling, error management,
5
+ # and response normalization. It establishes HTTP connections to Perplexity's API endpoint, processes chat
6
+ # completions and embeddings, handles various API error responses, and includes comprehensive response classes
7
+ # to format Perplexity's API responses into a consistent interface.
8
+
9
+ require 'faraday'
10
+ require 'json'
11
+ require 'event_stream_parser'
12
+ require 'durable/llm/errors'
13
+ require 'durable/llm/providers/base'
14
+
15
+ module Durable
16
+ module Llm
17
+ module Providers
18
+ # The Perplexity provider class for interacting with Perplexity's API.
19
+ #
20
+ # This class provides methods for text completion, embedding generation, streaming responses,
21
+ # and model listing using Perplexity's language models. It handles authentication, HTTP
22
+ # communication, error handling, and response normalization to provide a consistent interface
23
+ # for Perplexity's API services.
24
+ class Perplexity < Durable::Llm::Providers::Base
25
+ BASE_URL = 'https://api.perplexity.ai'
26
+
27
+ def default_api_key
28
+ begin
29
+ Durable::Llm.configuration.perplexity&.api_key
30
+ rescue NoMethodError
31
+ nil
32
+ end || ENV['PERPLEXITY_API_KEY']
33
+ end
34
+
35
+ attr_accessor :api_key
36
+
37
+ def initialize(api_key: nil)
38
+ super
39
+ @conn = Faraday.new(url: BASE_URL) do |faraday|
40
+ faraday.request :json
41
+ faraday.response :json
42
+ faraday.adapter Faraday.default_adapter
43
+ end
44
+ end
45
+
46
+ def completion(options)
47
+ response = @conn.post('chat/completions') do |req|
48
+ req.headers['Authorization'] = "Bearer #{@api_key}"
49
+ req.body = options
50
+ end
51
+
52
+ handle_response(response)
53
+ end
54
+
55
+ def embedding(model:, input:, **options)
56
+ response = @conn.post('embeddings') do |req|
57
+ req.headers['Authorization'] = "Bearer #{@api_key}"
58
+ req.body = { model: model, input: input, **options }
59
+ end
60
+
61
+ handle_response(response, PerplexityEmbeddingResponse)
62
+ end
63
+
64
+ def models
65
+ response = @conn.get('models') do |req|
66
+ req.headers['Authorization'] = "Bearer #{@api_key}"
67
+ end
68
+
69
+ handle_response(response).data.map { |model| model['id'] }
70
+ end
71
+
72
+ def self.stream?
73
+ true
74
+ end
75
+
76
+ def stream(options)
77
+ options[:stream] = true
78
+
79
+ response = @conn.post('chat/completions') do |req|
80
+ req.headers['Authorization'] = "Bearer #{@api_key}"
81
+ req.headers['Accept'] = 'text/event-stream'
82
+
83
+ options['temperature'] = options['temperature'].to_f if options['temperature']
84
+
85
+ req.body = options
86
+
87
+ user_proc = proc do |chunk, _size, _total|
88
+ yield PerplexityStreamResponse.new(chunk)
89
+ end
90
+
91
+ req.options.on_data = to_json_stream(user_proc: user_proc)
92
+ end
93
+
94
+ handle_response(response)
95
+ end
96
+
97
+ private
98
+
99
+ # CODE-FROM: ruby-openai @ https://github.com/alexrudall/ruby-openai/blob/main/lib/openai/http.rb
100
+ # MIT License: https://github.com/alexrudall/ruby-openai/blob/main/LICENSE.md
101
+ def to_json_stream(user_proc:)
102
+ parser = EventStreamParser::Parser.new
103
+
104
+ proc do |chunk, _bytes, env|
105
+ if env && env.status != 200
106
+ raise_error = Faraday::Response::RaiseError.new
107
+ raise_error.on_complete(env.merge(body: try_parse_json(chunk)))
108
+ end
109
+
110
+ parser.feed(chunk) do |_type, data|
111
+ user_proc.call(JSON.parse(data)) unless data == '[DONE]'
112
+ end
113
+ end
114
+ end
115
+
116
+ def try_parse_json(maybe_json)
117
+ JSON.parse(maybe_json)
118
+ rescue JSON::ParserError
119
+ maybe_json
120
+ end
121
+
122
+ # END-CODE-FROM
123
+
124
+ def handle_response(response, response_class = PerplexityResponse)
125
+ case response.status
126
+ when 200..299
127
+ response_class.new(response.body)
128
+ when 401
129
+ raise Durable::Llm::AuthenticationError, parse_error_message(response)
130
+ when 429
131
+ raise Durable::Llm::RateLimitError, parse_error_message(response)
132
+ when 400..499
133
+ raise Durable::Llm::InvalidRequestError, parse_error_message(response)
134
+ when 500..599
135
+ raise Durable::Llm::ServerError, parse_error_message(response)
136
+ else
137
+ raise Durable::Llm::APIError, "Unexpected response code: #{response.status}"
138
+ end
139
+ end
140
+
141
+ def parse_error_message(response)
142
+ body = begin
143
+ JSON.parse(response.body)
144
+ rescue StandardError
145
+ nil
146
+ end
147
+ message = body&.dig('error', 'message') || response.body
148
+ "#{response.status} Error: #{message}"
149
+ end
150
+
151
+ # Response class for Perplexity API completion responses.
152
+ #
153
+ # Wraps the raw API response and provides access to choices and data.
154
+ class PerplexityResponse
155
+ attr_reader :raw_response
156
+
157
+ def initialize(response)
158
+ @raw_response = response
159
+ end
160
+
161
+ def choices
162
+ @raw_response['choices'].map { |choice| PerplexityChoice.new(choice) }
163
+ end
164
+
165
+ def data
166
+ @raw_response['data']
167
+ end
168
+
169
+ def to_s
170
+ choices.map(&:to_s).join(' ')
171
+ end
172
+ end
173
+
174
+ # Represents a single choice in a Perplexity completion response.
175
+ #
176
+ # Contains the message and finish reason for the choice.
177
+ class PerplexityChoice
178
+ attr_reader :message, :finish_reason
179
+
180
+ def initialize(choice)
181
+ @message = PerplexityMessage.new(choice['message'])
182
+ @finish_reason = choice['finish_reason']
183
+ end
184
+
185
+ def to_s
186
+ @message.to_s
187
+ end
188
+ end
189
+
190
+ # Represents a message in a Perplexity response.
191
+ #
192
+ # Contains the role and content of the message.
193
+ class PerplexityMessage
194
+ attr_reader :role, :content
195
+
196
+ def initialize(message)
197
+ @role = message['role']
198
+ @content = message['content']
199
+ end
200
+
201
+ def to_s
202
+ @content
203
+ end
204
+ end
205
+
206
+ # Response class for Perplexity streaming API responses.
207
+ #
208
+ # Wraps streaming chunks and provides access to choices.
209
+ class PerplexityStreamResponse
210
+ attr_reader :choices
211
+
212
+ def initialize(parsed)
213
+ @choices = PerplexityStreamChoice.new(parsed['choices'])
214
+ end
215
+
216
+ def to_s
217
+ @choices.to_s
218
+ end
219
+ end
220
+
221
+ # Response class for Perplexity embedding API responses.
222
+ #
223
+ # Provides access to the embedding vector data.
224
+ class PerplexityEmbeddingResponse
225
+ attr_reader :embedding
226
+
227
+ def initialize(data)
228
+ @embedding = data.dig('data', 0, 'embedding')
229
+ end
230
+
231
+ def to_a
232
+ @embedding
233
+ end
234
+ end
235
+
236
+ # Represents a single choice in a Perplexity streaming response.
237
+ #
238
+ # Contains the delta and finish reason for the streaming choice.
239
+ class PerplexityStreamChoice
240
+ attr_reader :delta, :finish_reason
241
+
242
+ def initialize(choice)
243
+ @choice = [choice].flatten.first
244
+ @delta = PerplexityStreamDelta.new(@choice['delta'])
245
+ @finish_reason = @choice['finish_reason']
246
+ end
247
+
248
+ def to_s
249
+ @delta.to_s
250
+ end
251
+ end
252
+
253
+ # Represents a delta (incremental content) in a Perplexity streaming response.
254
+ #
255
+ # Contains the role and content delta for streaming updates.
256
+ class PerplexityStreamDelta
257
+ attr_reader :role, :content
258
+
259
+ def initialize(delta)
260
+ @role = delta['role']
261
+ @content = delta['content']
262
+ end
263
+
264
+ def to_s
265
+ @content || ''
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
271
+ end
272
+
273
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.