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
@@ -1,22 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Anthropic provider for Claude models with completion and streaming support.
1
4
 
2
5
  require 'faraday'
3
6
  require 'json'
4
7
  require 'durable/llm/errors'
5
8
  require 'durable/llm/providers/base'
9
+ require 'event_stream_parser'
6
10
 
7
11
  module Durable
8
12
  module Llm
9
13
  module Providers
14
+ # Anthropic provider for accessing Claude language models through their API.
15
+ #
16
+ # This provider implements the Durable::Llm::Providers::Base interface to provide
17
+ # completion and streaming capabilities for Anthropic's Claude models including
18
+ # Claude 3.5 Sonnet, Claude 3 Opus, and Claude 3 Haiku. It handles authentication
19
+ # via API keys, supports system messages, and provides comprehensive error handling
20
+ # for various Anthropic API error conditions.
21
+ #
22
+ # Key features:
23
+ # - Message-based chat completions with multi-turn conversations
24
+ # - Real-time streaming responses for interactive applications
25
+ # - System message support for setting context
26
+ # - Automatic model listing from predefined supported models
27
+ # - Comprehensive error handling with specific exception types
28
+ #
29
+ # @example Basic completion
30
+ # provider = Durable::Llm::Providers::Anthropic.new(api_key: 'your-api-key')
31
+ # response = provider.completion(
32
+ # model: 'claude-3-5-sonnet-20240620',
33
+ # messages: [{ role: 'user', content: 'Hello, world!' }]
34
+ # )
35
+ # puts response.choices.first.to_s
36
+ #
37
+ # @example Completion with system message
38
+ # response = provider.completion(
39
+ # model: 'claude-3-5-sonnet-20240620',
40
+ # messages: [
41
+ # { role: 'system', content: 'You are a helpful assistant.' },
42
+ # { role: 'user', content: 'Hello!' }
43
+ # ]
44
+ # )
45
+ #
46
+ # @example Streaming response
47
+ # provider.stream(model: 'claude-3-5-sonnet-20240620', messages: messages) do |chunk|
48
+ # print chunk.to_s
49
+ # end
50
+ #
51
+ # @see https://docs.anthropic.com/claude/docs/messages-overview Anthropic Messages API Documentation
10
52
  class Anthropic < Durable::Llm::Providers::Base
11
53
  BASE_URL = 'https://api.anthropic.com'
12
54
 
55
+ # @return [String, nil] The default API key for Anthropic, or nil if not configured
13
56
  def default_api_key
14
57
  Durable::Llm.configuration.anthropic&.api_key || ENV['ANTHROPIC_API_KEY']
15
58
  end
16
59
 
60
+ # @!attribute [rw] api_key
61
+ # @return [String, nil] The API key used for authentication with Anthropic
17
62
  attr_accessor :api_key
18
63
 
64
+ # Initializes a new Anthropic provider instance.
65
+ #
66
+ # @param api_key [String, nil] The Anthropic API key. If nil, uses default_api_key
67
+ # @return [Anthropic] A new Anthropic provider instance
19
68
  def initialize(api_key: nil)
69
+ super()
20
70
  @api_key = api_key || default_api_key
21
71
 
22
72
  @conn = Faraday.new(url: BASE_URL) do |faraday|
@@ -26,38 +76,111 @@ module Durable
26
76
  end
27
77
  end
28
78
 
79
+ # Performs a completion request to Anthropic's messages API.
80
+ #
81
+ # @param options [Hash] The completion options
82
+ # @option options [String] :model The Claude model to use
83
+ # @option options [Array<Hash>] :messages Array of message objects with role and content
84
+ # @option options [Integer] :max_tokens Maximum number of tokens to generate (default: 1024)
85
+ # @option options [Float] :temperature Sampling temperature between 0 and 1
86
+ # @option options [String] :system System message to set context
87
+ # @return [AnthropicResponse] The completion response object
88
+ # @raise [Durable::Llm::AuthenticationError] If API key is invalid
89
+ # @raise [Durable::Llm::RateLimitError] If rate limit is exceeded
90
+ # @raise [Durable::Llm::InvalidRequestError] If request parameters are invalid
91
+ # @raise [Durable::Llm::ServerError] If Anthropic's servers encounter an error
29
92
  def completion(options)
30
- options['max_tokens'] ||=1024
93
+ # Convert symbol keys to strings for consistency
94
+ options = options.transform_keys(&:to_s)
95
+
96
+ # Ensure max_tokens is set
97
+ options['max_tokens'] ||= 1024
98
+
99
+ # Handle system message separately as Anthropic expects it as a top-level parameter
100
+ system_message = nil
101
+ messages = options['messages']&.dup || []
102
+ if messages.first && (messages.first['role'] || messages.first[:role]) == 'system'
103
+ system_message = messages.first['content'] || messages.first[:content]
104
+ messages = messages[1..] || []
105
+ end
106
+
107
+ request_body = options.merge('messages' => messages)
108
+ request_body['system'] = system_message if system_message
109
+
31
110
  response = @conn.post('/v1/messages') do |req|
32
111
  req.headers['x-api-key'] = @api_key
33
112
  req.headers['anthropic-version'] = '2023-06-01'
34
- req.body = options
113
+ req.body = request_body
35
114
  end
36
115
 
37
116
  handle_response(response)
38
117
  end
39
118
 
119
+ # Retrieves the list of available models for this provider instance.
120
+ #
121
+ # @return [Array<String>] The list of available Claude model names
40
122
  def models
41
123
  self.class.models
42
124
  end
125
+
126
+ # Retrieves the list of supported Claude models.
127
+ #
128
+ # @return [Array<String>] Array of supported Claude model identifiers
43
129
  def self.models
44
130
  ['claude-3-5-sonnet-20240620', 'claude-3-opus-20240229', 'claude-3-haiku-20240307']
45
131
  end
46
132
 
133
+ # @return [Boolean] True, indicating this provider supports streaming
47
134
  def self.stream?
48
135
  true
49
136
  end
50
- def stream(options, &block)
51
- options[:stream] = true
137
+
138
+ # Performs an embedding request (not supported by Anthropic).
139
+ #
140
+ # @param model [String] The model to use for generating embeddings
141
+ # @param input [String, Array<String>] The input text(s) to embed
142
+ # @param options [Hash] Additional options for the embedding request
143
+ # @raise [NotImplementedError] Anthropic does not provide embedding APIs
144
+ def embedding(model:, input:, **options)
145
+ raise NotImplementedError, 'Anthropic does not provide embedding APIs'
146
+ end
147
+
148
+ # Performs a streaming completion request to Anthropic's messages API.
149
+ #
150
+ # @param options [Hash] The stream options (same as completion plus stream: true)
151
+ # @yield [AnthropicStreamResponse] Yields stream response chunks as they arrive
152
+ # @return [Object] The final response object
153
+ # @raise [Durable::Llm::AuthenticationError] If API key is invalid
154
+ # @raise [Durable::Llm::RateLimitError] If rate limit is exceeded
155
+ # @raise [Durable::Llm::InvalidRequestError] If request parameters are invalid
156
+ # @raise [Durable::Llm::ServerError] If Anthropic's servers encounter an error
157
+ def stream(options)
158
+ options = options.transform_keys(&:to_s)
159
+ options['stream'] = true
160
+
161
+ # Handle system message separately
162
+ system_message = nil
163
+ messages = options['messages']&.dup || []
164
+ if messages.first && (messages.first['role'] || messages.first[:role]) == 'system'
165
+ system_message = messages.first['content'] || messages.first[:content]
166
+ messages = messages[1..] || []
167
+ end
168
+
169
+ request_body = options.merge('messages' => messages)
170
+ request_body['system'] = system_message if system_message
171
+
52
172
  response = @conn.post('/v1/messages') do |req|
53
173
  req.headers['x-api-key'] = @api_key
54
174
  req.headers['anthropic-version'] = '2023-06-01'
55
175
  req.headers['Accept'] = 'text/event-stream'
56
- req.body = options
57
- req.options.on_data = Proc.new do |chunk, size, total|
58
- next if chunk.strip.empty?
59
- yield AnthropicStreamResponse.new(chunk) if chunk.start_with?('data: ')
176
+
177
+ req.body = request_body
178
+
179
+ user_proc = proc do |chunk, _size, _total|
180
+ yield AnthropicStreamResponse.new(chunk)
60
181
  end
182
+
183
+ req.options.on_data = to_json_stream(user_proc: user_proc)
61
184
  end
62
185
 
63
186
  handle_response(response)
@@ -65,23 +188,82 @@ module Durable
65
188
 
66
189
  private
67
190
 
191
+ # Converts JSON stream chunks to individual data objects for processing.
192
+ #
193
+ # This method handles Server-Sent Events from Anthropic's streaming API.
194
+ # It parses the event stream and yields individual JSON objects for each data chunk.
195
+ #
196
+ # @param user_proc [Proc] The proc to call with each parsed JSON object
197
+ # @return [Proc] A proc that can be used as Faraday's on_data callback
198
+ def to_json_stream(user_proc:)
199
+ parser = EventStreamParser::Parser.new
200
+
201
+ proc do |chunk, _bytes, env|
202
+ if env && env.status != 200
203
+ raise_error = Faraday::Response::RaiseError.new
204
+ raise_error.on_complete(env.merge(body: try_parse_json(chunk)))
205
+ end
206
+
207
+ parser.feed(chunk) do |_type, data|
208
+ user_proc.call(JSON.parse(data)) unless data == '[DONE]'
209
+ end
210
+ end
211
+ end
212
+
213
+ # Attempts to parse a string as JSON, returning the string if parsing fails.
214
+ #
215
+ # @param maybe_json [String] The string that might be JSON
216
+ # @return [Hash, Array, String] The parsed JSON object or the original string
217
+ def try_parse_json(maybe_json)
218
+ JSON.parse(maybe_json)
219
+ rescue JSON::ParserError
220
+ maybe_json
221
+ end
222
+
223
+ # Processes the API response and handles errors appropriately.
224
+ #
225
+ # @param response [Faraday::Response] The HTTP response from the API
226
+ # @return [AnthropicResponse] An instance of AnthropicResponse for successful responses
227
+ # @raise [Durable::Llm::AuthenticationError] For 401 responses
228
+ # @raise [Durable::Llm::RateLimitError] For 429 responses
229
+ # @raise [Durable::Llm::InvalidRequestError] For 4xx client errors
230
+ # @raise [Durable::Llm::ServerError] For 5xx server errors
231
+ # @raise [Durable::Llm::APIError] For unexpected status codes
68
232
  def handle_response(response)
69
- case response.status
70
- when 200..299
71
- AnthropicResponse.new(response.body)
72
- when 401
73
- raise Durable::Llm::AuthenticationError, response.body.dig('error', 'message')
74
- when 429
75
- raise Durable::Llm::RateLimitError, response.body.dig('error', 'message')
76
- when 400..499
77
- raise Durable::Llm::InvalidRequestError, response.body.dig('error', 'message')
78
- when 500..599
79
- raise Durable::Llm::ServerError, response.body.dig('error', 'message')
80
- else
81
- raise Durable::Llm::APIError, "Unexpected response code: #{response.status}"
82
- end
233
+ case response.status
234
+ when 200..299
235
+ AnthropicResponse.new(response.body)
236
+ when 401
237
+ raise Durable::Llm::AuthenticationError, parse_error_message(response)
238
+ when 429
239
+ raise Durable::Llm::RateLimitError, parse_error_message(response)
240
+ when 400..499
241
+ raise Durable::Llm::InvalidRequestError, parse_error_message(response)
242
+ when 500..599
243
+ raise Durable::Llm::ServerError, parse_error_message(response)
244
+ else
245
+ raise Durable::Llm::APIError, "Unexpected response code: #{response.status}"
246
+ end
247
+ end
248
+
249
+ # Extracts and formats error messages from API error responses.
250
+ #
251
+ # @param response [Faraday::Response] The error response from the API
252
+ # @return [String] The formatted error message
253
+ def parse_error_message(response)
254
+ body = begin
255
+ JSON.parse(response.body)
256
+ rescue StandardError
257
+ nil
258
+ end
259
+ message = body&.dig('error', 'message') || response.body
260
+ "#{response.status} Error: #{message}"
83
261
  end
84
262
 
263
+ # Response object for Anthropic messages API responses.
264
+ #
265
+ # This class wraps the raw response from Anthropic's messages endpoint
266
+ # and provides a consistent interface for accessing content and metadata.
85
267
  class AnthropicResponse
86
268
  attr_reader :raw_response
87
269
 
@@ -90,7 +272,11 @@ module Durable
90
272
  end
91
273
 
92
274
  def choices
93
- [@raw_response['content']].map { |content| AnthropicChoice.new(content) }
275
+ [AnthropicChoice.new(@raw_response)]
276
+ end
277
+
278
+ def usage
279
+ @raw_response['usage']
94
280
  end
95
281
 
96
282
  def to_s
@@ -98,11 +284,15 @@ module Durable
98
284
  end
99
285
  end
100
286
 
287
+ # Represents a single choice in an Anthropic messages response.
288
+ #
289
+ # Anthropic typically returns only one choice, containing the assistant's message.
101
290
  class AnthropicChoice
102
- attr_reader :message
291
+ attr_reader :message, :stop_reason
103
292
 
104
- def initialize(content)
105
- @message = AnthropicMessage.new(content)
293
+ def initialize(response)
294
+ @message = AnthropicMessage.new(response)
295
+ @stop_reason = response['stop_reason']
106
296
  end
107
297
 
108
298
  def to_s
@@ -110,12 +300,15 @@ module Durable
110
300
  end
111
301
  end
112
302
 
303
+ # Represents a message in an Anthropic conversation.
304
+ #
305
+ # Messages have a role (user, assistant) and content composed of text blocks.
113
306
  class AnthropicMessage
114
307
  attr_reader :role, :content
115
308
 
116
- def initialize(content)
117
- @role = [content].flatten.map { |_| _['type']}.join(' ')
118
- @content = [content].flatten.map { |_| _['text']}.join(' ')
309
+ def initialize(response)
310
+ @role = response['role']
311
+ @content = response['content']&.map { |block| block['text'] }&.join(' ') || ''
119
312
  end
120
313
 
121
314
  def to_s
@@ -123,12 +316,21 @@ module Durable
123
316
  end
124
317
  end
125
318
 
319
+ # Response object for streaming Anthropic messages chunks.
320
+ #
321
+ # This wraps individual chunks from the Server-Sent Events stream,
322
+ # providing access to the incremental content updates.
126
323
  class AnthropicStreamResponse
127
- attr_reader :choices
128
-
129
- def initialize(fragment)
130
- parsed = JSON.parse(fragment.split("data: ").last)
131
- @choices = [AnthropicStreamChoice.new(parsed['delta'])]
324
+ attr_reader :choices, :type
325
+
326
+ def initialize(parsed)
327
+ @type = parsed['type']
328
+ @choices = case @type
329
+ when 'content_block_delta'
330
+ [AnthropicStreamChoice.new(parsed)]
331
+ else
332
+ []
333
+ end
132
334
  end
133
335
 
134
336
  def to_s
@@ -136,11 +338,14 @@ module Durable
136
338
  end
137
339
  end
138
340
 
341
+ # Represents a single choice in a streaming Anthropic response chunk.
342
+ #
343
+ # Contains the delta (incremental content) for the choice.
139
344
  class AnthropicStreamChoice
140
345
  attr_reader :delta
141
346
 
142
- def initialize(delta)
143
- @delta = AnthropicStreamDelta.new(delta)
347
+ def initialize(event)
348
+ @delta = AnthropicStreamDelta.new(event['delta'])
144
349
  end
145
350
 
146
351
  def to_s
@@ -148,6 +353,9 @@ module Durable
148
353
  end
149
354
  end
150
355
 
356
+ # Represents the incremental content delta in a streaming response.
357
+ #
358
+ # Contains the type and text content of the delta.
151
359
  class AnthropicStreamDelta
152
360
  attr_reader :type, :text
153
361
 
@@ -164,3 +372,5 @@ module Durable
164
372
  end
165
373
  end
166
374
  end
375
+
376
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.