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,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.