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.
- checksums.yaml +4 -4
- data/.envrc +7 -0
- data/CHANGELOG.md +5 -0
- data/CONFIGURE.md +132 -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 +247 -60
- data/lib/durable/llm/client.rb +92 -11
- data/lib/durable/llm/configuration.rb +174 -23
- data/lib/durable/llm/errors.rb +185 -0
- data/lib/durable/llm/providers/anthropic.rb +246 -36
- data/lib/durable/llm/providers/azure_openai.rb +347 -0
- data/lib/durable/llm/providers/base.rb +106 -9
- data/lib/durable/llm/providers/cohere.rb +227 -0
- 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 +108 -29
- data/lib/durable/llm/providers/huggingface.rb +122 -18
- data/lib/durable/llm/providers/mistral.rb +431 -0
- data/lib/durable/llm/providers/openai.rb +162 -25
- 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 -15
- data/lib/durable/llm/version.rb +5 -1
- data/lib/durable/llm.rb +143 -3
- data/lib/durable.rb +29 -4
- data/sig/durable/llm.rbs +302 -1
- metadata +50 -36
@@ -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,50 +1,147 @@
|
|
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
|
-
raise NotImplementedError,
|
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
|
|
15
|
-
|
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
|
16
56
|
def completion(options)
|
17
|
-
raise NotImplementedError,
|
57
|
+
raise NotImplementedError, 'Subclasses must implement completion'
|
58
|
+
end
|
59
|
+
|
60
|
+
# Retrieves the list of available models, with caching
|
61
|
+
#
|
62
|
+
# @return [Array<String>] The list of available model names
|
63
|
+
def self.models
|
64
|
+
cache_dir = File.expand_path("#{Dir.home}/.local/durable-llm/cache")
|
65
|
+
|
66
|
+
FileUtils.mkdir_p(cache_dir) unless File.directory?(cache_dir)
|
67
|
+
cache_file = File.join(cache_dir, "#{name.split('::').last}.json")
|
68
|
+
|
69
|
+
file_exists = File.exist?(cache_file)
|
70
|
+
file_new_enough = file_exists && File.mtime(cache_file) > Time.now - 3600
|
71
|
+
|
72
|
+
if file_exists && file_new_enough
|
73
|
+
JSON.parse(File.read(cache_file))
|
74
|
+
else
|
75
|
+
models = new.models
|
76
|
+
File.write(cache_file, JSON.generate(models)) if models.length.positive?
|
77
|
+
models
|
78
|
+
end
|
18
79
|
end
|
19
80
|
|
20
|
-
|
21
|
-
|
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]
|
22
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
|
23
92
|
def models
|
24
|
-
raise NotImplementedError,
|
93
|
+
raise NotImplementedError, 'Subclasses must implement models'
|
25
94
|
end
|
26
95
|
|
96
|
+
# Checks if this provider class supports streaming
|
97
|
+
#
|
98
|
+
# @return [Boolean] True if streaming is supported, false otherwise
|
27
99
|
def self.stream?
|
28
100
|
false
|
29
101
|
end
|
102
|
+
|
103
|
+
# Checks if this provider instance supports streaming
|
104
|
+
#
|
105
|
+
# @return [Boolean] True if streaming is supported, false otherwise
|
30
106
|
def stream?
|
31
107
|
self.class.stream?
|
32
108
|
end
|
33
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
|
34
116
|
def stream(options, &block)
|
35
|
-
raise NotImplementedError,
|
117
|
+
raise NotImplementedError, 'Subclasses must implement stream'
|
36
118
|
end
|
37
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
|
38
127
|
def embedding(model:, input:, **options)
|
39
|
-
raise NotImplementedError,
|
128
|
+
raise NotImplementedError, 'Subclasses must implement embedding'
|
40
129
|
end
|
41
130
|
|
42
131
|
private
|
43
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
|
44
139
|
def handle_response(response)
|
45
|
-
raise NotImplementedError,
|
140
|
+
raise NotImplementedError, 'Subclasses must implement handle_response'
|
46
141
|
end
|
47
142
|
end
|
48
143
|
end
|
49
144
|
end
|
50
145
|
end
|
146
|
+
|
147
|
+
# Copyright (c) 2025 Durable Programming, LLC. All rights reserved.
|