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.
- checksums.yaml +4 -4
- data/.envrc +7 -0
- data/CHANGELOG.md +5 -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 +215 -22
- data/lib/durable/llm/client.rb +85 -6
- data/lib/durable/llm/configuration.rb +163 -10
- data/lib/durable/llm/errors.rb +185 -0
- data/lib/durable/llm/providers/anthropic.rb +232 -24
- data/lib/durable/llm/providers/azure_openai.rb +347 -0
- data/lib/durable/llm/providers/base.rb +83 -1
- data/lib/durable/llm/providers/cohere.rb +138 -11
- 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 +107 -25
- data/lib/durable/llm/providers/huggingface.rb +120 -17
- data/lib/durable/llm/providers/mistral.rb +431 -0
- data/lib/durable/llm/providers/openai.rb +150 -4
- 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 -13
- data/lib/durable/llm/version.rb +5 -1
- data/lib/durable/llm.rb +141 -1
- data/lib/durable.rb +29 -4
- data/sig/durable/llm.rbs +302 -1
- metadata +48 -36
@@ -0,0 +1,233 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# DeepSeek provider for language model API access with completion, embedding, and streaming support.
|
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
|
+
# DeepSeek provider for language model API interactions
|
15
|
+
class DeepSeek < Durable::Llm::Providers::Base
|
16
|
+
BASE_URL = 'https://api.deepseek.com'
|
17
|
+
|
18
|
+
def default_api_key
|
19
|
+
Durable::Llm.configuration.deepseek&.api_key || ENV['DEEPSEEK_API_KEY']
|
20
|
+
end
|
21
|
+
|
22
|
+
attr_accessor :api_key
|
23
|
+
|
24
|
+
def initialize(api_key: nil)
|
25
|
+
super()
|
26
|
+
@api_key = api_key || default_api_key
|
27
|
+
@conn = Faraday.new(url: BASE_URL) do |faraday|
|
28
|
+
faraday.request :json
|
29
|
+
faraday.response :json
|
30
|
+
faraday.adapter Faraday.default_adapter
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def completion(options)
|
35
|
+
response = @conn.post('chat/completions') do |req|
|
36
|
+
req.headers['Authorization'] = "Bearer #{@api_key}"
|
37
|
+
req.body = options
|
38
|
+
end
|
39
|
+
|
40
|
+
handle_response(response)
|
41
|
+
end
|
42
|
+
|
43
|
+
def embedding(model:, input:, **options)
|
44
|
+
response = @conn.post('embeddings') do |req|
|
45
|
+
req.headers['Authorization'] = "Bearer #{@api_key}"
|
46
|
+
req.body = { model: model, input: input, **options }
|
47
|
+
end
|
48
|
+
|
49
|
+
handle_response(response, DeepSeekEmbeddingResponse)
|
50
|
+
end
|
51
|
+
|
52
|
+
def models
|
53
|
+
response = @conn.get('models') do |req|
|
54
|
+
req.headers['Authorization'] = "Bearer #{@api_key}"
|
55
|
+
end
|
56
|
+
|
57
|
+
handle_response(response).data.map { |model| model['id'] }
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.stream?
|
61
|
+
true
|
62
|
+
end
|
63
|
+
|
64
|
+
def stream(options)
|
65
|
+
opts = options.dup
|
66
|
+
opts[:stream] = true
|
67
|
+
opts['temperature'] = opts['temperature'].to_f if opts['temperature']
|
68
|
+
|
69
|
+
@conn.post('chat/completions') do |req|
|
70
|
+
req.headers['Authorization'] = "Bearer #{@api_key}"
|
71
|
+
req.headers['Accept'] = 'text/event-stream'
|
72
|
+
req.body = opts
|
73
|
+
req.options.on_data = to_json_stream(user_proc: proc { |chunk| yield DeepSeekStreamResponse.new(chunk) })
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
# CODE-FROM: ruby-openai @ https://github.com/alexrudall/ruby-openai/blob/main/lib/openai/http.rb
|
80
|
+
# MIT License: https://github.com/alexrudall/ruby-openai/blob/main/LICENSE.md
|
81
|
+
def to_json_stream(user_proc:)
|
82
|
+
parser = EventStreamParser::Parser.new
|
83
|
+
|
84
|
+
proc do |chunk, _bytes, env|
|
85
|
+
if env && env.status != 200
|
86
|
+
raise_error = Faraday::Response::RaiseError.new
|
87
|
+
raise_error.on_complete(env.merge(body: try_parse_json(chunk)))
|
88
|
+
end
|
89
|
+
|
90
|
+
parser.feed(chunk) do |_type, data|
|
91
|
+
user_proc.call(JSON.parse(data)) unless data == '[DONE]'
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def try_parse_json(maybe_json)
|
97
|
+
JSON.parse(maybe_json)
|
98
|
+
rescue JSON::ParserError
|
99
|
+
maybe_json
|
100
|
+
end
|
101
|
+
|
102
|
+
# END-CODE-FROM
|
103
|
+
|
104
|
+
def handle_response(response, response_class = DeepSeekResponse)
|
105
|
+
case response.status
|
106
|
+
when 200..299 then response_class.new(response.body)
|
107
|
+
when 401 then raise Durable::Llm::AuthenticationError, parse_error_message(response)
|
108
|
+
when 429 then raise Durable::Llm::RateLimitError, parse_error_message(response)
|
109
|
+
when 400..499 then raise Durable::Llm::InvalidRequestError, parse_error_message(response)
|
110
|
+
when 500..599 then raise Durable::Llm::ServerError, parse_error_message(response)
|
111
|
+
else raise Durable::Llm::APIError, "Unexpected response code: #{response.status}"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def parse_error_message(response)
|
116
|
+
body = begin
|
117
|
+
JSON.parse(response.body)
|
118
|
+
rescue StandardError
|
119
|
+
nil
|
120
|
+
end
|
121
|
+
message = body&.dig('error', 'message') || response.body
|
122
|
+
"#{response.status} Error: #{message}"
|
123
|
+
end
|
124
|
+
|
125
|
+
# Response wrapper for DeepSeek API responses
|
126
|
+
class DeepSeekResponse
|
127
|
+
attr_reader :raw_response
|
128
|
+
|
129
|
+
def initialize(response)
|
130
|
+
@raw_response = response
|
131
|
+
end
|
132
|
+
|
133
|
+
def choices
|
134
|
+
@raw_response['choices'].map { |choice| DeepSeekChoice.new(choice) }
|
135
|
+
end
|
136
|
+
|
137
|
+
def data
|
138
|
+
@raw_response['data']
|
139
|
+
end
|
140
|
+
|
141
|
+
def to_s
|
142
|
+
choices.map(&:to_s).join(' ')
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
# Choice wrapper for DeepSeek response choices
|
147
|
+
class DeepSeekChoice
|
148
|
+
attr_reader :message, :finish_reason
|
149
|
+
|
150
|
+
def initialize(choice)
|
151
|
+
@message = DeepSeekMessage.new(choice['message'])
|
152
|
+
@finish_reason = choice['finish_reason']
|
153
|
+
end
|
154
|
+
|
155
|
+
def to_s
|
156
|
+
@message.to_s
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
# Message wrapper for DeepSeek messages
|
161
|
+
class DeepSeekMessage
|
162
|
+
attr_reader :role, :content
|
163
|
+
|
164
|
+
def initialize(message)
|
165
|
+
@role = message['role']
|
166
|
+
@content = message['content']
|
167
|
+
end
|
168
|
+
|
169
|
+
def to_s
|
170
|
+
@content
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
# Stream response wrapper for DeepSeek streaming
|
175
|
+
class DeepSeekStreamResponse
|
176
|
+
attr_reader :choices
|
177
|
+
|
178
|
+
def initialize(parsed)
|
179
|
+
@choices = DeepSeekStreamChoice.new(parsed['choices'])
|
180
|
+
end
|
181
|
+
|
182
|
+
def to_s
|
183
|
+
@choices.to_s
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# Embedding response wrapper for DeepSeek embeddings
|
188
|
+
class DeepSeekEmbeddingResponse
|
189
|
+
attr_reader :embedding
|
190
|
+
|
191
|
+
def initialize(data)
|
192
|
+
@embedding = data.dig('data', 0, 'embedding')
|
193
|
+
end
|
194
|
+
|
195
|
+
def to_a
|
196
|
+
@embedding
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
# Stream choice wrapper for DeepSeek streaming
|
201
|
+
class DeepSeekStreamChoice
|
202
|
+
attr_reader :delta, :finish_reason
|
203
|
+
|
204
|
+
def initialize(choice)
|
205
|
+
@choice = [choice].flatten.first
|
206
|
+
@delta = DeepSeekStreamDelta.new(@choice['delta'])
|
207
|
+
@finish_reason = @choice['finish_reason']
|
208
|
+
end
|
209
|
+
|
210
|
+
def to_s
|
211
|
+
@delta.to_s
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# Stream delta wrapper for DeepSeek streaming
|
216
|
+
class DeepSeekStreamDelta
|
217
|
+
attr_reader :role, :content
|
218
|
+
|
219
|
+
def initialize(delta)
|
220
|
+
@role = delta['role']
|
221
|
+
@content = delta['content']
|
222
|
+
end
|
223
|
+
|
224
|
+
def to_s
|
225
|
+
@content || ''
|
226
|
+
end
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
# Copyright (c) 2025 Durable Programming, LLC. All rights reserved.
|
@@ -0,0 +1,278 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This file implements the Fireworks AI provider for accessing Fireworks AI's language models through their API, providing completion, embedding, and streaming capabilities with authentication handling, error management, and response normalization. It establishes HTTP connections to Fireworks AI's API endpoint, processes chat completions and embeddings, handles various API error responses, and includes comprehensive response classes to format Fireworks AI's API responses into a consistent interface.
|
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
|
+
class Fireworks < Durable::Llm::Providers::Base
|
15
|
+
BASE_URL = 'https://api.fireworks.ai/inference/v1'
|
16
|
+
|
17
|
+
def default_api_key
|
18
|
+
Durable::Llm.configuration.fireworks&.api_key || ENV['FIREWORKS_API_KEY']
|
19
|
+
end
|
20
|
+
|
21
|
+
attr_accessor :api_key
|
22
|
+
|
23
|
+
# Initializes a new Fireworks provider instance.
|
24
|
+
#
|
25
|
+
# @param api_key [String, nil] The API key for Fireworks AI. If not provided, uses the default from configuration or environment.
|
26
|
+
# @return [Fireworks] A new instance of the Fireworks provider.
|
27
|
+
def initialize(api_key: nil)
|
28
|
+
super()
|
29
|
+
@api_key = api_key || default_api_key
|
30
|
+
@conn = Faraday.new(url: BASE_URL) do |faraday|
|
31
|
+
faraday.request :json
|
32
|
+
faraday.response :json
|
33
|
+
faraday.adapter Faraday.default_adapter
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Performs a chat completion request to Fireworks AI.
|
38
|
+
#
|
39
|
+
# @param options [Hash] The completion options including model, messages, temperature, etc.
|
40
|
+
# @return [FireworksResponse] The response object containing the completion results.
|
41
|
+
# @raise [Durable::Llm::AuthenticationError] If authentication fails.
|
42
|
+
# @raise [Durable::Llm::RateLimitError] If rate limit is exceeded.
|
43
|
+
# @raise [Durable::Llm::InvalidRequestError] If the request is invalid.
|
44
|
+
# @raise [Durable::Llm::ServerError] If there's a server error.
|
45
|
+
def completion(options)
|
46
|
+
response = @conn.post('chat/completions') do |req|
|
47
|
+
req.headers['Authorization'] = "Bearer #{@api_key}"
|
48
|
+
req.body = options
|
49
|
+
end
|
50
|
+
|
51
|
+
handle_response(response)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Generates embeddings for the given input using Fireworks AI.
|
55
|
+
#
|
56
|
+
# @param model [String] The model to use for generating embeddings.
|
57
|
+
# @param input [String, Array<String>] The text input(s) to embed.
|
58
|
+
# @param options [Hash] Additional options for the embedding request.
|
59
|
+
# @return [FireworksEmbeddingResponse] The response object containing the embeddings.
|
60
|
+
# @raise [Durable::Llm::AuthenticationError] If authentication fails.
|
61
|
+
# @raise [Durable::Llm::RateLimitError] If rate limit is exceeded.
|
62
|
+
# @raise [Durable::Llm::InvalidRequestError] If the request is invalid.
|
63
|
+
# @raise [Durable::Llm::ServerError] If there's a server error.
|
64
|
+
def embedding(model:, input:, **options)
|
65
|
+
response = @conn.post('embeddings') do |req|
|
66
|
+
req.headers['Authorization'] = "Bearer #{@api_key}"
|
67
|
+
req.body = { model: model, input: input, **options }
|
68
|
+
end
|
69
|
+
|
70
|
+
handle_response(response, FireworksEmbeddingResponse)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Retrieves the list of available models from Fireworks AI.
|
74
|
+
#
|
75
|
+
# @return [Array<String>] An array of model IDs available for use.
|
76
|
+
# @raise [Durable::Llm::AuthenticationError] If authentication fails.
|
77
|
+
# @raise [Durable::Llm::RateLimitError] If rate limit is exceeded.
|
78
|
+
# @raise [Durable::Llm::InvalidRequestError] If the request is invalid.
|
79
|
+
# @raise [Durable::Llm::ServerError] If there's a server error.
|
80
|
+
def models
|
81
|
+
response = @conn.get('models') do |req|
|
82
|
+
req.headers['Authorization'] = "Bearer #{@api_key}"
|
83
|
+
end
|
84
|
+
|
85
|
+
handle_response(response).data.map { |model| model['id'] }
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.stream?
|
89
|
+
true
|
90
|
+
end
|
91
|
+
|
92
|
+
# Performs a streaming chat completion request to Fireworks AI.
|
93
|
+
#
|
94
|
+
# @param options [Hash] The completion options including model, messages, temperature, etc.
|
95
|
+
# @yield [FireworksStreamResponse] Yields each chunk of the streaming response.
|
96
|
+
# @return [nil] Returns nil after streaming is complete.
|
97
|
+
# @raise [Durable::Llm::AuthenticationError] If authentication fails.
|
98
|
+
# @raise [Durable::Llm::RateLimitError] If rate limit is exceeded.
|
99
|
+
# @raise [Durable::Llm::InvalidRequestError] If the request is invalid.
|
100
|
+
# @raise [Durable::Llm::ServerError] If there's a server error.
|
101
|
+
def stream(options)
|
102
|
+
options[:stream] = true
|
103
|
+
|
104
|
+
@conn.post('chat/completions') do |req|
|
105
|
+
req.headers['Authorization'] = "Bearer #{@api_key}"
|
106
|
+
req.headers['Accept'] = 'text/event-stream'
|
107
|
+
|
108
|
+
options['temperature'] = options['temperature'].to_f if options['temperature']
|
109
|
+
|
110
|
+
req.body = options
|
111
|
+
|
112
|
+
user_proc = proc do |chunk, _size, _total|
|
113
|
+
yield FireworksStreamResponse.new(chunk)
|
114
|
+
end
|
115
|
+
|
116
|
+
req.options.on_data = to_json_stream(user_proc: user_proc)
|
117
|
+
end
|
118
|
+
|
119
|
+
# For streaming, errors are handled in to_json_stream, no need for handle_response
|
120
|
+
nil
|
121
|
+
end
|
122
|
+
|
123
|
+
private
|
124
|
+
|
125
|
+
# CODE-FROM: ruby-openai @ https://github.com/alexrudall/ruby-openai/blob/main/lib/openai/http.rb
|
126
|
+
# MIT License: https://github.com/alexrudall/ruby-openai/blob/main/LICENSE.md
|
127
|
+
def to_json_stream(user_proc:)
|
128
|
+
parser = EventStreamParser::Parser.new
|
129
|
+
|
130
|
+
proc do |chunk, _bytes, env|
|
131
|
+
if env && env.status != 200
|
132
|
+
raise_error = Faraday::Response::RaiseError.new
|
133
|
+
raise_error.on_complete(env.merge(body: try_parse_json(chunk)))
|
134
|
+
end
|
135
|
+
|
136
|
+
parser.feed(chunk) do |_type, data|
|
137
|
+
user_proc.call(JSON.parse(data)) unless data == '[DONE]'
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def try_parse_json(maybe_json)
|
143
|
+
JSON.parse(maybe_json)
|
144
|
+
rescue JSON::ParserError
|
145
|
+
maybe_json
|
146
|
+
end
|
147
|
+
|
148
|
+
# END-CODE-FROM
|
149
|
+
|
150
|
+
def handle_response(response, response_class = FireworksResponse)
|
151
|
+
case response.status
|
152
|
+
when 200..299
|
153
|
+
response_class.new(response.body)
|
154
|
+
when 401
|
155
|
+
raise Durable::Llm::AuthenticationError, parse_error_message(response)
|
156
|
+
when 429
|
157
|
+
raise Durable::Llm::RateLimitError, parse_error_message(response)
|
158
|
+
when 400..499
|
159
|
+
raise Durable::Llm::InvalidRequestError, parse_error_message(response)
|
160
|
+
when 500..599
|
161
|
+
raise Durable::Llm::ServerError, parse_error_message(response)
|
162
|
+
else
|
163
|
+
raise Durable::Llm::APIError, "Unexpected response code: #{response.status}"
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def parse_error_message(response)
|
168
|
+
body = begin
|
169
|
+
JSON.parse(response.body)
|
170
|
+
rescue StandardError
|
171
|
+
nil
|
172
|
+
end
|
173
|
+
message = body&.dig('error', 'message') || response.body
|
174
|
+
"#{response.status} Error: #{message}"
|
175
|
+
end
|
176
|
+
|
177
|
+
class FireworksResponse
|
178
|
+
attr_reader :raw_response
|
179
|
+
|
180
|
+
def initialize(response)
|
181
|
+
@raw_response = response
|
182
|
+
end
|
183
|
+
|
184
|
+
def choices
|
185
|
+
@raw_response['choices'].map { |choice| FireworksChoice.new(choice) }
|
186
|
+
end
|
187
|
+
|
188
|
+
def data
|
189
|
+
@raw_response['data']
|
190
|
+
end
|
191
|
+
|
192
|
+
def to_s
|
193
|
+
choices.map(&:to_s).join(' ')
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
class FireworksChoice
|
198
|
+
attr_reader :message, :finish_reason
|
199
|
+
|
200
|
+
def initialize(choice)
|
201
|
+
@message = FireworksMessage.new(choice['message'])
|
202
|
+
@finish_reason = choice['finish_reason']
|
203
|
+
end
|
204
|
+
|
205
|
+
def to_s
|
206
|
+
@message.to_s
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
class FireworksMessage
|
211
|
+
attr_reader :role, :content
|
212
|
+
|
213
|
+
def initialize(message)
|
214
|
+
@role = message['role']
|
215
|
+
@content = message['content']
|
216
|
+
end
|
217
|
+
|
218
|
+
def to_s
|
219
|
+
@content
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
class FireworksStreamResponse
|
224
|
+
attr_reader :choices
|
225
|
+
|
226
|
+
def initialize(parsed)
|
227
|
+
@choices = FireworksStreamChoice.new(parsed['choices'])
|
228
|
+
end
|
229
|
+
|
230
|
+
def to_s
|
231
|
+
@choices.to_s
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
class FireworksEmbeddingResponse
|
236
|
+
attr_reader :embedding
|
237
|
+
|
238
|
+
def initialize(data)
|
239
|
+
@embedding = data.dig('data', 0, 'embedding')
|
240
|
+
end
|
241
|
+
|
242
|
+
def to_a
|
243
|
+
@embedding
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
class FireworksStreamChoice
|
248
|
+
attr_reader :delta, :finish_reason
|
249
|
+
|
250
|
+
def initialize(choice)
|
251
|
+
@choice = [choice].flatten.first
|
252
|
+
@delta = FireworksStreamDelta.new(@choice['delta'])
|
253
|
+
@finish_reason = @choice['finish_reason']
|
254
|
+
end
|
255
|
+
|
256
|
+
def to_s
|
257
|
+
@delta.to_s
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
class FireworksStreamDelta
|
262
|
+
attr_reader :role, :content
|
263
|
+
|
264
|
+
def initialize(delta)
|
265
|
+
@role = delta['role']
|
266
|
+
@content = delta['content']
|
267
|
+
end
|
268
|
+
|
269
|
+
def to_s
|
270
|
+
@content || ''
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
# Copyright (c) 2025 Durable Programming, LLC. All rights reserved.
|