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,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Azure OpenAI provider implementation for Durable LLM
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
+ # Azure OpenAI provider for accessing Azure OpenAI's language models
15
+ #
16
+ # This provider implements the Azure OpenAI API for chat completions,
17
+ # embeddings, and streaming. It handles authentication via API keys,
18
+ # deployment-based routing, and response normalization.
19
+ class AzureOpenai < Durable::Llm::Providers::Base
20
+ BASE_URL_TEMPLATE = 'https://%s.openai.azure.com/openai/deployments/%s'
21
+
22
+ def default_api_key
23
+ begin
24
+ Durable::Llm.configuration.azure_openai&.api_key
25
+ rescue NoMethodError
26
+ nil
27
+ end || ENV['AZURE_OPENAI_API_KEY']
28
+ end
29
+
30
+ attr_accessor :api_key, :resource_name, :api_version
31
+
32
+ def initialize(api_key: nil, resource_name: nil, api_version: '2024-02-01')
33
+ super(api_key: api_key)
34
+ @resource_name = resource_name || ENV['AZURE_OPENAI_RESOURCE_NAME']
35
+ @api_version = api_version
36
+ # NOTE: BASE_URL will be constructed per request since deployment is in model
37
+ end
38
+
39
+ def completion(options)
40
+ model = options.delete(:model) || options.delete('model')
41
+ base_url = format(BASE_URL_TEMPLATE, @resource_name, model)
42
+ conn = build_connection(base_url)
43
+
44
+ response = conn.post('chat/completions') do |req|
45
+ req.headers['api-key'] = @api_key
46
+ req.params['api-version'] = @api_version
47
+ req.body = options
48
+ end
49
+
50
+ handle_response(response)
51
+ end
52
+
53
+ def embedding(model:, input:, **options)
54
+ base_url = format(BASE_URL_TEMPLATE, @resource_name, model)
55
+ conn = build_connection(base_url)
56
+
57
+ response = conn.post('embeddings') do |req|
58
+ req.headers['api-key'] = @api_key
59
+ req.params['api-version'] = @api_version
60
+ req.body = { input: input, **options }
61
+ end
62
+
63
+ handle_response(response, AzureOpenaiEmbeddingResponse)
64
+ end
65
+
66
+ def models
67
+ # Azure OpenAI doesn't have a public models endpoint, return hardcoded list
68
+ [
69
+ # GPT-5 series
70
+ 'gpt-5',
71
+ 'gpt-5-mini',
72
+ 'gpt-5-nano',
73
+ 'gpt-5-chat',
74
+ 'gpt-5-codex',
75
+ 'gpt-5-pro',
76
+ # GPT-4.1 series
77
+ 'gpt-4.1',
78
+ 'gpt-4.1-mini',
79
+ 'gpt-4.1-nano',
80
+ # GPT-4o series
81
+ 'gpt-4o',
82
+ 'gpt-4o-mini',
83
+ 'gpt-4o-audio-preview',
84
+ 'gpt-4o-mini-audio-preview',
85
+ 'gpt-4o-realtime-preview',
86
+ 'gpt-4o-mini-realtime-preview',
87
+ 'gpt-4o-transcribe',
88
+ 'gpt-4o-mini-transcribe',
89
+ 'gpt-4o-mini-tts',
90
+ # GPT-4 Turbo
91
+ 'gpt-4-turbo',
92
+ # GPT-4
93
+ 'gpt-4',
94
+ 'gpt-4-32k',
95
+ # GPT-3.5
96
+ 'gpt-3.5-turbo',
97
+ 'gpt-35-turbo',
98
+ 'gpt-35-turbo-instruct',
99
+ # O-series
100
+ 'o3',
101
+ 'o3-mini',
102
+ 'o3-pro',
103
+ 'o4-mini',
104
+ 'o1',
105
+ 'o1-mini',
106
+ 'o1-preview',
107
+ 'codex-mini',
108
+ # Embeddings
109
+ 'text-embedding-ada-002',
110
+ 'text-embedding-3-small',
111
+ 'text-embedding-3-large',
112
+ # Audio
113
+ 'whisper',
114
+ 'gpt-4o-transcribe',
115
+ 'gpt-4o-mini-transcribe',
116
+ 'tts',
117
+ 'tts-hd',
118
+ 'gpt-4o-mini-tts',
119
+ # Image generation
120
+ 'dall-e-3',
121
+ 'gpt-image-1',
122
+ 'gpt-image-1-mini',
123
+ # Video generation
124
+ 'sora',
125
+ # Other
126
+ 'model-router',
127
+ 'computer-use-preview',
128
+ 'gpt-oss-120b',
129
+ 'gpt-oss-20b'
130
+ ]
131
+ end
132
+
133
+ def self.stream?
134
+ true
135
+ end
136
+
137
+ def stream(options)
138
+ model = options[:model] || options['model']
139
+ base_url = format(BASE_URL_TEMPLATE, @resource_name, model)
140
+ conn = build_connection(base_url)
141
+
142
+ options[:stream] = true
143
+ options['temperature'] = options['temperature'].to_f if options['temperature']
144
+
145
+ response = conn.post('chat/completions') do |req|
146
+ setup_stream_request(req, options) do |chunk|
147
+ yield AzureOpenaiStreamResponse.new(chunk)
148
+ end
149
+ end
150
+
151
+ handle_response(response)
152
+ end
153
+
154
+ def setup_stream_request(req, options)
155
+ req.headers['api-key'] = @api_key
156
+ req.params['api-version'] = @api_version
157
+ req.headers['Accept'] = 'text/event-stream'
158
+ req.body = options
159
+
160
+ user_proc = proc do |chunk, _size, _total|
161
+ yield chunk
162
+ end
163
+
164
+ req.options.on_data = to_json_stream(user_proc: user_proc)
165
+ end
166
+
167
+ private
168
+
169
+ def build_connection(base_url)
170
+ Faraday.new(url: base_url) do |faraday|
171
+ faraday.request :json
172
+ faraday.response :json
173
+ faraday.adapter Faraday.default_adapter
174
+ end
175
+ end
176
+
177
+ # CODE-FROM: ruby-openai @ https://github.com/alexrudall/ruby-openai/blob/main/lib/openai/http.rb
178
+ # MIT License: https://github.com/alexrudall/ruby-openai/blob/main/LICENSE.md
179
+ def to_json_stream(user_proc:)
180
+ parser = EventStreamParser::Parser.new
181
+
182
+ proc do |chunk, _bytes, env|
183
+ if env && env.status != 200
184
+ raise_error = Faraday::Response::RaiseError.new
185
+ raise_error.on_complete(env.merge(body: try_parse_json(chunk)))
186
+ end
187
+
188
+ parser.feed(chunk) do |_type, data|
189
+ user_proc.call(JSON.parse(data)) unless data == '[DONE]'
190
+ end
191
+ end
192
+ end
193
+
194
+ def try_parse_json(maybe_json)
195
+ JSON.parse(maybe_json)
196
+ rescue JSON::ParserError
197
+ maybe_json
198
+ end
199
+
200
+ # END-CODE-FROM
201
+
202
+ def handle_response(response, response_class = AzureOpenaiResponse)
203
+ case response.status
204
+ when 200..299
205
+ response_class.new(response.body)
206
+ else
207
+ raise_error(response)
208
+ end
209
+ end
210
+
211
+ def raise_error(response)
212
+ error_class = case response.status
213
+ when 401 then Durable::Llm::AuthenticationError
214
+ when 429 then Durable::Llm::RateLimitError
215
+ when 400..499 then Durable::Llm::InvalidRequestError
216
+ when 500..599 then Durable::Llm::ServerError
217
+ else Durable::Llm::APIError
218
+ end
219
+
220
+ message = if error_class == Durable::Llm::APIError
221
+ "Unexpected response code: #{response.status}"
222
+ else
223
+ parse_error_message(response)
224
+ end
225
+
226
+ raise error_class, message
227
+ end
228
+
229
+ def parse_error_message(response)
230
+ body = begin
231
+ JSON.parse(response.body)
232
+ rescue StandardError
233
+ nil
234
+ end
235
+ message = body&.dig('error', 'message') || response.body
236
+ "#{response.status} Error: #{message}"
237
+ end
238
+
239
+ # Response wrapper for Azure OpenAI completion API responses
240
+ class AzureOpenaiResponse
241
+ attr_reader :raw_response
242
+
243
+ def initialize(response)
244
+ @raw_response = response
245
+ end
246
+
247
+ def choices
248
+ @raw_response['choices'].map { |choice| AzureOpenaiChoice.new(choice) }
249
+ end
250
+
251
+ def data
252
+ @raw_response['data']
253
+ end
254
+
255
+ def to_s
256
+ choices.map(&:to_s).join(' ')
257
+ end
258
+ end
259
+
260
+ # Choice wrapper for Azure OpenAI API responses
261
+ class AzureOpenaiChoice
262
+ attr_reader :message, :finish_reason
263
+
264
+ def initialize(choice)
265
+ @message = AzureOpenaiMessage.new(choice['message'])
266
+ @finish_reason = choice['finish_reason']
267
+ end
268
+
269
+ def to_s
270
+ @message.to_s
271
+ end
272
+ end
273
+
274
+ # Message wrapper for Azure OpenAI API responses
275
+ class AzureOpenaiMessage
276
+ attr_reader :role, :content
277
+
278
+ def initialize(message)
279
+ @role = message['role']
280
+ @content = message['content']
281
+ end
282
+
283
+ def to_s
284
+ @content
285
+ end
286
+ end
287
+
288
+ # Stream response wrapper for Azure OpenAI streaming API
289
+ class AzureOpenaiStreamResponse
290
+ attr_reader :choices
291
+
292
+ def initialize(parsed)
293
+ @choices = AzureOpenaiStreamChoice.new(parsed['choices'])
294
+ end
295
+
296
+ def to_s
297
+ @choices.to_s
298
+ end
299
+ end
300
+
301
+ # Embedding response wrapper for Azure OpenAI embedding API
302
+ class AzureOpenaiEmbeddingResponse
303
+ attr_reader :embedding
304
+
305
+ def initialize(data)
306
+ @embedding = data.dig('data', 0, 'embedding')
307
+ end
308
+
309
+ def to_a
310
+ @embedding
311
+ end
312
+ end
313
+
314
+ # Stream choice wrapper for Azure OpenAI streaming responses
315
+ class AzureOpenaiStreamChoice
316
+ attr_reader :delta, :finish_reason
317
+
318
+ def initialize(choice)
319
+ @choice = [choice].flatten.first
320
+ @delta = AzureOpenaiStreamDelta.new(@choice['delta'])
321
+ @finish_reason = @choice['finish_reason']
322
+ end
323
+
324
+ def to_s
325
+ @delta.to_s
326
+ end
327
+ end
328
+
329
+ # Stream delta wrapper for Azure OpenAI streaming responses
330
+ class AzureOpenaiStreamDelta
331
+ attr_reader :role, :content
332
+
333
+ def initialize(delta)
334
+ @role = delta['role']
335
+ @content = delta['content']
336
+ end
337
+
338
+ def to_s
339
+ @content || ''
340
+ end
341
+ end
342
+ end
343
+ end
344
+ end
345
+ end
346
+
347
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.
@@ -1,21 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ # This file defines the abstract base class for all LLM providers in the Durable gem,
7
+ # establishing a common interface and shared functionality that all provider implementations
8
+ # must follow. It defines required methods like completion, models, and streaming capabilities,
9
+ # provides caching mechanisms for model lists, handles default API key resolution, and includes
10
+ # stub implementations for optional features like embeddings. The base class ensures consistency
11
+ # across different LLM providers while allowing each provider to implement their specific API
12
+ # communication patterns and response handling.
13
+
1
14
  module Durable
2
15
  module Llm
3
16
  module Providers
17
+ # Abstract base class for all LLM providers
18
+ #
19
+ # This class defines the common interface that all LLM provider implementations must follow.
20
+ # It provides default implementations for caching model lists, handling API keys, and stub
21
+ # implementations for optional features.
22
+ #
23
+ # Subclasses must implement the following methods:
24
+ # - default_api_key
25
+ # - completion
26
+ # - models
27
+ # - handle_response
28
+ #
29
+ # Subclasses may override:
30
+ # - stream?
31
+ # - stream
32
+ # - embedding
4
33
  class Base
34
+ # @return [String, nil] The default API key for this provider, or nil if not configured
35
+ # @raise [NotImplementedError] Subclasses must implement this method
5
36
  def default_api_key
6
37
  raise NotImplementedError, 'Subclasses must implement default_api_key'
7
38
  end
8
39
 
40
+ # @!attribute [rw] api_key
41
+ # @return [String, nil] The API key used for authentication
9
42
  attr_accessor :api_key
10
43
 
44
+ # Initializes a new provider instance
45
+ #
46
+ # @param api_key [String, nil] The API key to use for authentication. If nil, uses default_api_key
11
47
  def initialize(api_key: nil)
12
48
  @api_key = api_key || default_api_key
13
49
  end
14
50
 
51
+ # Performs a completion request
52
+ #
53
+ # @param options [Hash] The completion options including model, messages, etc.
54
+ # @return [Object] The completion response object
55
+ # @raise [NotImplementedError] Subclasses must implement this method
15
56
  def completion(options)
16
57
  raise NotImplementedError, 'Subclasses must implement completion'
17
58
  end
18
59
 
60
+ # Retrieves the list of available models, with caching
61
+ #
62
+ # @return [Array<String>] The list of available model names
19
63
  def self.models
20
64
  cache_dir = File.expand_path("#{Dir.home}/.local/durable-llm/cache")
21
65
 
@@ -29,33 +73,69 @@ module Durable
29
73
  JSON.parse(File.read(cache_file))
30
74
  else
31
75
  models = new.models
32
- File.write(cache_file, JSON.generate(models)) if models.length > 0
76
+ File.write(cache_file, JSON.generate(models)) if models.length.positive?
33
77
  models
34
78
  end
35
79
  end
36
80
 
81
+ # Returns the list of supported option names for completions
82
+ #
83
+ # @return [Array<String>] The supported option names
84
+ def self.options
85
+ %w[temperature max_tokens top_p frequency_penalty presence_penalty]
86
+ end
87
+
88
+ # Retrieves the list of available models for this provider instance
89
+ #
90
+ # @return [Array<String>] The list of available model names
91
+ # @raise [NotImplementedError] Subclasses must implement this method
37
92
  def models
38
93
  raise NotImplementedError, 'Subclasses must implement models'
39
94
  end
40
95
 
96
+ # Checks if this provider class supports streaming
97
+ #
98
+ # @return [Boolean] True if streaming is supported, false otherwise
41
99
  def self.stream?
42
100
  false
43
101
  end
44
102
 
103
+ # Checks if this provider instance supports streaming
104
+ #
105
+ # @return [Boolean] True if streaming is supported, false otherwise
45
106
  def stream?
46
107
  self.class.stream?
47
108
  end
48
109
 
110
+ # Performs a streaming completion request
111
+ #
112
+ # @param options [Hash] The stream options including model, messages, etc.
113
+ # @yield [Object] Yields stream response chunks as they arrive
114
+ # @return [Object] The final response object
115
+ # @raise [NotImplementedError] Subclasses must implement this method
49
116
  def stream(options, &block)
50
117
  raise NotImplementedError, 'Subclasses must implement stream'
51
118
  end
52
119
 
120
+ # Performs an embedding request
121
+ #
122
+ # @param model [String] The model to use for generating embeddings
123
+ # @param input [String, Array<String>] The input text(s) to embed
124
+ # @param options [Hash] Additional options for the embedding request
125
+ # @return [Object] The embedding response object
126
+ # @raise [NotImplementedError] Subclasses must implement this method
53
127
  def embedding(model:, input:, **options)
54
128
  raise NotImplementedError, 'Subclasses must implement embedding'
55
129
  end
56
130
 
57
131
  private
58
132
 
133
+ # Handles the raw response from the API, processing errors and returning normalized response
134
+ #
135
+ # @param response [Object] The raw response from the API call
136
+ # @return [Object] The processed response object
137
+ # @raise [Durable::Llm::APIError] If the response indicates an API error
138
+ # @raise [NotImplementedError] Subclasses must implement this method
59
139
  def handle_response(response)
60
140
  raise NotImplementedError, 'Subclasses must implement handle_response'
61
141
  end
@@ -63,3 +143,5 @@ module Durable
63
143
  end
64
144
  end
65
145
  end
146
+
147
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.
@@ -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,69 @@ 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
+
70
147
  class CohereResponse
71
148
  attr_reader :raw_response
72
149
 
@@ -75,7 +152,7 @@ module Durable
75
152
  end
76
153
 
77
154
  def choices
78
- [@raw_response.dig('message', 'content')].flatten.map { |generation| CohereChoice.new(generation) }
155
+ @raw_response.dig('message', 'content')&.map { |generation| CohereChoice.new(generation) } || []
79
156
  end
80
157
 
81
158
  def to_s
@@ -94,7 +171,57 @@ module Durable
94
171
  @text
95
172
  end
96
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
97
222
  end
98
223
  end
99
224
  end
100
225
  end
226
+
227
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.