durable-llm 0.1.3 → 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.
- checksums.yaml +4 -4
- data/.envrc +7 -0
- data/CHANGELOG.md +5 -0
- data/CONFIGURE.md +132 -0
- data/Gemfile +7 -9
- data/Gemfile.lock +3 -3
- data/README.md +1 -0
- data/Rakefile +6 -6
- data/devenv.lock +103 -0
- data/devenv.nix +9 -0
- data/devenv.yaml +15 -0
- data/durable-llm.gemspec +44 -0
- data/examples/openai_quick_complete.rb +3 -1
- data/lib/durable/llm/cli.rb +247 -60
- data/lib/durable/llm/client.rb +92 -11
- data/lib/durable/llm/configuration.rb +174 -23
- data/lib/durable/llm/errors.rb +185 -0
- data/lib/durable/llm/providers/anthropic.rb +246 -36
- data/lib/durable/llm/providers/azure_openai.rb +347 -0
- data/lib/durable/llm/providers/base.rb +106 -9
- data/lib/durable/llm/providers/cohere.rb +227 -0
- data/lib/durable/llm/providers/deepseek.rb +233 -0
- data/lib/durable/llm/providers/fireworks.rb +278 -0
- data/lib/durable/llm/providers/google.rb +301 -0
- data/lib/durable/llm/providers/groq.rb +108 -29
- data/lib/durable/llm/providers/huggingface.rb +122 -18
- data/lib/durable/llm/providers/mistral.rb +431 -0
- data/lib/durable/llm/providers/openai.rb +162 -25
- data/lib/durable/llm/providers/opencode.rb +253 -0
- data/lib/durable/llm/providers/openrouter.rb +256 -0
- data/lib/durable/llm/providers/perplexity.rb +273 -0
- data/lib/durable/llm/providers/together.rb +346 -0
- data/lib/durable/llm/providers/xai.rb +355 -0
- data/lib/durable/llm/providers.rb +103 -15
- data/lib/durable/llm/version.rb +5 -1
- data/lib/durable/llm.rb +143 -3
- data/lib/durable.rb +29 -4
- data/sig/durable/llm.rbs +302 -1
- metadata +50 -36
@@ -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.
|