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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +7 -0
  3. data/CHANGELOG.md +5 -0
  4. data/CONFIGURE.md +132 -0
  5. data/Gemfile +7 -9
  6. data/Gemfile.lock +3 -3
  7. data/README.md +1 -0
  8. data/Rakefile +6 -6
  9. data/devenv.lock +103 -0
  10. data/devenv.nix +9 -0
  11. data/devenv.yaml +15 -0
  12. data/durable-llm.gemspec +44 -0
  13. data/examples/openai_quick_complete.rb +3 -1
  14. data/lib/durable/llm/cli.rb +247 -60
  15. data/lib/durable/llm/client.rb +92 -11
  16. data/lib/durable/llm/configuration.rb +174 -23
  17. data/lib/durable/llm/errors.rb +185 -0
  18. data/lib/durable/llm/providers/anthropic.rb +246 -36
  19. data/lib/durable/llm/providers/azure_openai.rb +347 -0
  20. data/lib/durable/llm/providers/base.rb +106 -9
  21. data/lib/durable/llm/providers/cohere.rb +227 -0
  22. data/lib/durable/llm/providers/deepseek.rb +233 -0
  23. data/lib/durable/llm/providers/fireworks.rb +278 -0
  24. data/lib/durable/llm/providers/google.rb +301 -0
  25. data/lib/durable/llm/providers/groq.rb +108 -29
  26. data/lib/durable/llm/providers/huggingface.rb +122 -18
  27. data/lib/durable/llm/providers/mistral.rb +431 -0
  28. data/lib/durable/llm/providers/openai.rb +162 -25
  29. data/lib/durable/llm/providers/opencode.rb +253 -0
  30. data/lib/durable/llm/providers/openrouter.rb +256 -0
  31. data/lib/durable/llm/providers/perplexity.rb +273 -0
  32. data/lib/durable/llm/providers/together.rb +346 -0
  33. data/lib/durable/llm/providers/xai.rb +355 -0
  34. data/lib/durable/llm/providers.rb +103 -15
  35. data/lib/durable/llm/version.rb +5 -1
  36. data/lib/durable/llm.rb +143 -3
  37. data/lib/durable.rb +29 -4
  38. data/sig/durable/llm.rbs +302 -1
  39. metadata +50 -36
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file implements the Cohere provider for accessing Cohere's language models through their API.
4
+
5
+ require 'faraday'
6
+ require 'json'
7
+ require 'durable/llm/errors'
8
+ require 'durable/llm/providers/base'
9
+ require 'event_stream_parser'
10
+
11
+ module Durable
12
+ module Llm
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.
18
+ class Cohere < Durable::Llm::Providers::Base
19
+ BASE_URL = 'https://api.cohere.ai/v2'
20
+
21
+ def default_api_key
22
+ Durable::Llm.configuration.cohere&.api_key || ENV['COHERE_API_KEY']
23
+ end
24
+
25
+ attr_accessor :api_key
26
+
27
+ def initialize(api_key: nil)
28
+ super(api_key: api_key)
29
+ @conn = Faraday.new(url: BASE_URL) do |faraday|
30
+ faraday.request :json
31
+ faraday.response :json
32
+ faraday.adapter Faraday.default_adapter
33
+ end
34
+ end
35
+
36
+ def completion(options)
37
+ response = @conn.post('chat') do |req|
38
+ req.headers['Authorization'] = "Bearer #{@api_key}"
39
+ req.headers['Content-Type'] = 'application/json'
40
+ req.body = options
41
+ end
42
+
43
+ handle_response(response)
44
+ end
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
+
74
+ def models
75
+ response = @conn.get('../v1/models') do |req|
76
+ req.headers['Authorization'] = "Bearer #{@api_key}"
77
+ end
78
+
79
+ data = handle_response(response).raw_response
80
+ data['models']&.map { |model| model['name'] }
81
+ end
82
+
83
+ def self.stream?
84
+ true
85
+ end
86
+
87
+ private
88
+
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)
121
+ case response.status
122
+ when 200..299
123
+ response_class.new(response.body)
124
+ when 401
125
+ raise Durable::Llm::AuthenticationError, parse_error_message(response)
126
+ when 429
127
+ raise Durable::Llm::RateLimitError, parse_error_message(response)
128
+ when 400..499
129
+ raise Durable::Llm::InvalidRequestError, parse_error_message(response)
130
+ when 500..599
131
+ raise Durable::Llm::ServerError, parse_error_message(response)
132
+ else
133
+ raise Durable::Llm::APIError, "Unexpected response code: #{response.status}"
134
+ end
135
+ end
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
+ class CohereResponse
148
+ attr_reader :raw_response
149
+
150
+ def initialize(response)
151
+ @raw_response = response
152
+ end
153
+
154
+ def choices
155
+ @raw_response.dig('message', 'content')&.map { |generation| CohereChoice.new(generation) } || []
156
+ end
157
+
158
+ def to_s
159
+ choices.map(&:to_s).join(' ')
160
+ end
161
+ end
162
+
163
+ class CohereChoice
164
+ attr_reader :text
165
+
166
+ def initialize(generation)
167
+ @text = generation['text']
168
+ end
169
+
170
+ def to_s
171
+ @text
172
+ end
173
+ end
174
+
175
+ class CohereEmbeddingResponse
176
+ attr_reader :embedding
177
+
178
+ def initialize(data)
179
+ @embedding = data.dig('embeddings', 'float', 0)
180
+ end
181
+
182
+ def to_a
183
+ @embedding
184
+ end
185
+ end
186
+
187
+ class CohereStreamResponse
188
+ attr_reader :choices
189
+
190
+ def initialize(parsed)
191
+ @choices = [CohereStreamChoice.new(parsed['delta'])]
192
+ end
193
+
194
+ def to_s
195
+ @choices.map(&:to_s).join(' ')
196
+ end
197
+ end
198
+
199
+ class CohereStreamChoice
200
+ attr_reader :delta
201
+
202
+ def initialize(delta)
203
+ @delta = CohereStreamDelta.new(delta)
204
+ end
205
+
206
+ def to_s
207
+ @delta.to_s
208
+ end
209
+ end
210
+
211
+ class CohereStreamDelta
212
+ attr_reader :text
213
+
214
+ def initialize(delta)
215
+ @text = delta['text']
216
+ end
217
+
218
+ def to_s
219
+ @text || ''
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ # 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.