durable-llm 0.1.4 → 0.1.6

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +7 -0
  3. data/CHANGELOG.md +5 -0
  4. data/CLI.md +0 -2
  5. data/Gemfile +7 -9
  6. data/README.md +564 -30
  7. data/Rakefile +16 -6
  8. data/devenv.lock +171 -0
  9. data/devenv.nix +12 -0
  10. data/devenv.yaml +8 -0
  11. data/durable-llm.gemspec +52 -0
  12. data/examples/openai_quick_complete.rb +4 -2
  13. data/lib/durable/llm/cli.rb +218 -22
  14. data/lib/durable/llm/client.rb +228 -8
  15. data/lib/durable/llm/configuration.rb +163 -10
  16. data/lib/durable/llm/convenience.rb +102 -0
  17. data/lib/durable/llm/errors.rb +185 -0
  18. data/lib/durable/llm/provider_utilities.rb +201 -0
  19. data/lib/durable/llm/providers/anthropic.rb +232 -24
  20. data/lib/durable/llm/providers/azure_openai.rb +347 -0
  21. data/lib/durable/llm/providers/base.rb +220 -11
  22. data/lib/durable/llm/providers/cohere.rb +157 -11
  23. data/lib/durable/llm/providers/deepseek.rb +233 -0
  24. data/lib/durable/llm/providers/fireworks.rb +304 -0
  25. data/lib/durable/llm/providers/google.rb +327 -0
  26. data/lib/durable/llm/providers/groq.rb +133 -25
  27. data/lib/durable/llm/providers/huggingface.rb +120 -17
  28. data/lib/durable/llm/providers/mistral.rb +431 -0
  29. data/lib/durable/llm/providers/openai.rb +150 -4
  30. data/lib/durable/llm/providers/opencode.rb +253 -0
  31. data/lib/durable/llm/providers/openrouter.rb +256 -0
  32. data/lib/durable/llm/providers/perplexity.rb +273 -0
  33. data/lib/durable/llm/providers/together.rb +346 -0
  34. data/lib/durable/llm/providers/xai.rb +355 -0
  35. data/lib/durable/llm/providers.rb +113 -13
  36. data/lib/durable/llm/response_helpers.rb +185 -0
  37. data/lib/durable/llm/version.rb +5 -1
  38. data/lib/durable/llm.rb +214 -1
  39. data/lib/durable.rb +29 -4
  40. data/sig/durable/llm.rbs +303 -1
  41. metadata +106 -28
  42. data/Gemfile.lock +0 -103
@@ -1,11 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file implements the Cohere provider for accessing Cohere's language models through their API.
4
+
1
5
  require 'faraday'
2
6
  require 'json'
3
7
  require 'durable/llm/errors'
4
8
  require 'durable/llm/providers/base'
9
+ require 'event_stream_parser'
5
10
 
6
11
  module Durable
7
12
  module Llm
8
13
  module Providers
14
+ # Cohere provider for accessing Cohere's language models
15
+ #
16
+ # This class provides completion, embedding, and streaming capabilities
17
+ # for Cohere's API, including proper error handling and response normalization.
9
18
  class Cohere < Durable::Llm::Providers::Base
10
19
  BASE_URL = 'https://api.cohere.ai/v2'
11
20
 
@@ -16,7 +25,7 @@ module Durable
16
25
  attr_accessor :api_key
17
26
 
18
27
  def initialize(api_key: nil)
19
- @api_key = api_key || default_api_key
28
+ super(api_key: api_key)
20
29
  @conn = Faraday.new(url: BASE_URL) do |faraday|
21
30
  faraday.request :json
22
31
  faraday.response :json
@@ -34,10 +43,37 @@ module Durable
34
43
  handle_response(response)
35
44
  end
36
45
 
46
+ def stream(options)
47
+ options[:stream] = true
48
+
49
+ response = @conn.post('chat') do |req|
50
+ req.headers['Authorization'] = "Bearer #{@api_key}"
51
+ req.headers['Accept'] = 'text/event-stream'
52
+ req.body = options
53
+
54
+ user_proc = proc do |chunk, _size, _total|
55
+ yield CohereStreamResponse.new(chunk)
56
+ end
57
+
58
+ req.options.on_data = to_json_stream(user_proc: user_proc)
59
+ end
60
+
61
+ handle_response(response)
62
+ end
63
+
64
+ def embedding(model:, input:, **options)
65
+ response = @conn.post('embed') do |req|
66
+ req.headers['Authorization'] = "Bearer #{@api_key}"
67
+ req.headers['Content-Type'] = 'application/json'
68
+ req.body = { model: model, texts: Array(input), input_type: 'search_document', **options }
69
+ end
70
+
71
+ handle_response(response, CohereEmbeddingResponse)
72
+ end
73
+
37
74
  def models
38
- response = @conn.get('models') do |req|
75
+ response = @conn.get('../v1/models') do |req|
39
76
  req.headers['Authorization'] = "Bearer #{@api_key}"
40
- req.headers['OpenAI-Organization'] = @organization if @organization
41
77
  end
42
78
 
43
79
  data = handle_response(response).raw_response
@@ -45,28 +81,73 @@ module Durable
45
81
  end
46
82
 
47
83
  def self.stream?
48
- false
84
+ true
49
85
  end
50
86
 
51
87
  private
52
88
 
53
- def handle_response(response)
89
+ # CODE-FROM: ruby-openai @ https://github.com/alexrudall/ruby-openai/blob/main/lib/openai/http.rb
90
+ # MIT License: https://github.com/alexrudall/ruby-openai/blob/main/LICENSE.md
91
+ # Given a proc, returns an outer proc that can be used to iterate over a JSON stream of chunks.
92
+ # For each chunk, the inner user_proc is called giving it the JSON object. The JSON object could
93
+ # be a data object or an error object as described in the Cohere API documentation.
94
+ #
95
+ # @param user_proc [Proc] The inner proc to call for each JSON object in the chunk.
96
+ # @return [Proc] An outer proc that iterates over a raw stream, converting it to JSON.
97
+ def to_json_stream(user_proc:)
98
+ parser = EventStreamParser::Parser.new
99
+
100
+ proc do |chunk, _bytes, env|
101
+ if env && env.status != 200
102
+ raise_error = Faraday::Response::RaiseError.new
103
+ raise_error.on_complete(env.merge(body: try_parse_json(chunk)))
104
+ end
105
+
106
+ parser.feed(chunk) do |_type, data|
107
+ user_proc.call(JSON.parse(data)) unless data == '[DONE]'
108
+ end
109
+ end
110
+ end
111
+
112
+ def try_parse_json(maybe_json)
113
+ JSON.parse(maybe_json)
114
+ rescue JSON::ParserError
115
+ maybe_json
116
+ end
117
+
118
+ # END-CODE-FROM
119
+
120
+ def handle_response(response, response_class = CohereResponse)
54
121
  case response.status
55
122
  when 200..299
56
- CohereResponse.new(response.body)
123
+ response_class.new(response.body)
57
124
  when 401
58
- raise Durable::Llm::AuthenticationError, response.body['message']
125
+ raise Durable::Llm::AuthenticationError, parse_error_message(response)
59
126
  when 429
60
- raise Durable::Llm::RateLimitError, response.body['message']
127
+ raise Durable::Llm::RateLimitError, parse_error_message(response)
61
128
  when 400..499
62
- raise Durable::Llm::InvalidRequestError, response.body['message']
129
+ raise Durable::Llm::InvalidRequestError, parse_error_message(response)
63
130
  when 500..599
64
- raise Durable::Llm::ServerError, response.body['message']
131
+ raise Durable::Llm::ServerError, parse_error_message(response)
65
132
  else
66
133
  raise Durable::Llm::APIError, "Unexpected response code: #{response.status}"
67
134
  end
68
135
  end
69
136
 
137
+ def parse_error_message(response)
138
+ body = begin
139
+ JSON.parse(response.body)
140
+ rescue StandardError
141
+ nil
142
+ end
143
+ message = body&.dig('message') || response.body
144
+ "#{response.status} Error: #{message}"
145
+ end
146
+
147
+ # Response object for Cohere chat API responses.
148
+ #
149
+ # Wraps the raw response and provides a consistent interface for accessing
150
+ # message content and metadata.
70
151
  class CohereResponse
71
152
  attr_reader :raw_response
72
153
 
@@ -75,7 +156,7 @@ module Durable
75
156
  end
76
157
 
77
158
  def choices
78
- [@raw_response.dig('message', 'content')].flatten.map { |generation| CohereChoice.new(generation) }
159
+ @raw_response.dig('message', 'content')&.map { |generation| CohereChoice.new(generation) } || []
79
160
  end
80
161
 
81
162
  def to_s
@@ -83,6 +164,9 @@ module Durable
83
164
  end
84
165
  end
85
166
 
167
+ # Represents a single choice in a Cohere response.
168
+ #
169
+ # Contains the generated text content.
86
170
  class CohereChoice
87
171
  attr_reader :text
88
172
 
@@ -94,7 +178,69 @@ module Durable
94
178
  @text
95
179
  end
96
180
  end
181
+
182
+ # Response object for Cohere embedding API responses.
183
+ #
184
+ # Wraps embedding data and provides array access to the vector representation.
185
+ class CohereEmbeddingResponse
186
+ attr_reader :embedding
187
+
188
+ def initialize(data)
189
+ @embedding = data.dig('embeddings', 'float', 0)
190
+ end
191
+
192
+ def to_a
193
+ @embedding
194
+ end
195
+ end
196
+
197
+ # Response object for streaming Cohere chat chunks.
198
+ #
199
+ # Wraps individual chunks from the Server-Sent Events stream.
200
+ class CohereStreamResponse
201
+ attr_reader :choices
202
+
203
+ def initialize(parsed)
204
+ @choices = [CohereStreamChoice.new(parsed['delta'])]
205
+ end
206
+
207
+ def to_s
208
+ @choices.map(&:to_s).join(' ')
209
+ end
210
+ end
211
+
212
+ # Represents a single choice in a streaming Cohere response chunk.
213
+ #
214
+ # Contains the delta (incremental content) for the choice.
215
+ class CohereStreamChoice
216
+ attr_reader :delta
217
+
218
+ def initialize(delta)
219
+ @delta = CohereStreamDelta.new(delta)
220
+ end
221
+
222
+ def to_s
223
+ @delta.to_s
224
+ end
225
+ end
226
+
227
+ # Represents the incremental content delta in a streaming response.
228
+ #
229
+ # Contains the text content of the delta.
230
+ class CohereStreamDelta
231
+ attr_reader :text
232
+
233
+ def initialize(delta)
234
+ @text = delta['text']
235
+ end
236
+
237
+ def to_s
238
+ @text || ''
239
+ end
240
+ end
97
241
  end
98
242
  end
99
243
  end
100
244
  end
245
+
246
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.
@@ -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.