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.
@@ -1,21 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Anthropic provider for Claude models with completion and streaming support.
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
+ # 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
9
52
  class Anthropic < Durable::Llm::Providers::Base
10
53
  BASE_URL = 'https://api.anthropic.com'
11
54
 
55
+ # @return [String, nil] The default API key for Anthropic, or nil if not configured
12
56
  def default_api_key
13
57
  Durable::Llm.configuration.anthropic&.api_key || ENV['ANTHROPIC_API_KEY']
14
58
  end
15
59
 
60
+ # @!attribute [rw] api_key
61
+ # @return [String, nil] The API key used for authentication with Anthropic
16
62
  attr_accessor :api_key
17
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
18
68
  def initialize(api_key: nil)
69
+ super()
19
70
  @api_key = api_key || default_api_key
20
71
 
21
72
  @conn = Faraday.new(url: BASE_URL) do |faraday|
@@ -25,41 +76,111 @@ module Durable
25
76
  end
26
77
  end
27
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
28
92
  def completion(options)
93
+ # Convert symbol keys to strings for consistency
94
+ options = options.transform_keys(&:to_s)
95
+
96
+ # Ensure max_tokens is set
29
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
+
30
110
  response = @conn.post('/v1/messages') do |req|
31
111
  req.headers['x-api-key'] = @api_key
32
112
  req.headers['anthropic-version'] = '2023-06-01'
33
- req.body = options
113
+ req.body = request_body
34
114
  end
35
115
 
36
116
  handle_response(response)
37
117
  end
38
118
 
119
+ # Retrieves the list of available models for this provider instance.
120
+ #
121
+ # @return [Array<String>] The list of available Claude model names
39
122
  def models
40
123
  self.class.models
41
124
  end
42
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
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
51
157
  def stream(options)
52
- options[:stream] = true
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
+
53
172
  response = @conn.post('/v1/messages') do |req|
54
173
  req.headers['x-api-key'] = @api_key
55
174
  req.headers['anthropic-version'] = '2023-06-01'
56
175
  req.headers['Accept'] = 'text/event-stream'
57
- req.body = options
58
- req.options.on_data = proc do |chunk, _size, _total|
59
- next if chunk.strip.empty?
60
176
 
61
- yield AnthropicStreamResponse.new(chunk) if chunk.start_with?('data: ')
177
+ req.body = request_body
178
+
179
+ user_proc = proc do |chunk, _size, _total|
180
+ yield AnthropicStreamResponse.new(chunk)
62
181
  end
182
+
183
+ req.options.on_data = to_json_stream(user_proc: user_proc)
63
184
  end
64
185
 
65
186
  handle_response(response)
@@ -67,23 +188,82 @@ module Durable
67
188
 
68
189
  private
69
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
70
232
  def handle_response(response)
71
233
  case response.status
72
234
  when 200..299
73
235
  AnthropicResponse.new(response.body)
74
236
  when 401
75
- raise Durable::Llm::AuthenticationError, response.body.dig('error', 'message')
237
+ raise Durable::Llm::AuthenticationError, parse_error_message(response)
76
238
  when 429
77
- raise Durable::Llm::RateLimitError, response.body.dig('error', 'message')
239
+ raise Durable::Llm::RateLimitError, parse_error_message(response)
78
240
  when 400..499
79
- raise Durable::Llm::InvalidRequestError, response.body.dig('error', 'message')
241
+ raise Durable::Llm::InvalidRequestError, parse_error_message(response)
80
242
  when 500..599
81
- raise Durable::Llm::ServerError, response.body.dig('error', 'message')
243
+ raise Durable::Llm::ServerError, parse_error_message(response)
82
244
  else
83
245
  raise Durable::Llm::APIError, "Unexpected response code: #{response.status}"
84
246
  end
85
247
  end
86
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}"
261
+ end
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.
87
267
  class AnthropicResponse
88
268
  attr_reader :raw_response
89
269
 
@@ -92,7 +272,11 @@ module Durable
92
272
  end
93
273
 
94
274
  def choices
95
- [@raw_response['content']].map { |content| AnthropicChoice.new(content) }
275
+ [AnthropicChoice.new(@raw_response)]
276
+ end
277
+
278
+ def usage
279
+ @raw_response['usage']
96
280
  end
97
281
 
98
282
  def to_s
@@ -100,11 +284,15 @@ module Durable
100
284
  end
101
285
  end
102
286
 
287
+ # Represents a single choice in an Anthropic messages response.
288
+ #
289
+ # Anthropic typically returns only one choice, containing the assistant's message.
103
290
  class AnthropicChoice
104
- attr_reader :message
291
+ attr_reader :message, :stop_reason
105
292
 
106
- def initialize(content)
107
- @message = AnthropicMessage.new(content)
293
+ def initialize(response)
294
+ @message = AnthropicMessage.new(response)
295
+ @stop_reason = response['stop_reason']
108
296
  end
109
297
 
110
298
  def to_s
@@ -112,12 +300,15 @@ module Durable
112
300
  end
113
301
  end
114
302
 
303
+ # Represents a message in an Anthropic conversation.
304
+ #
305
+ # Messages have a role (user, assistant) and content composed of text blocks.
115
306
  class AnthropicMessage
116
307
  attr_reader :role, :content
117
308
 
118
- def initialize(content)
119
- @role = [content].flatten.map { |_| _['type'] }.join(' ')
120
- @content = [content].flatten.map { |_| _['text'] }.join(' ')
309
+ def initialize(response)
310
+ @role = response['role']
311
+ @content = response['content']&.map { |block| block['text'] }&.join(' ') || ''
121
312
  end
122
313
 
123
314
  def to_s
@@ -125,12 +316,21 @@ module Durable
125
316
  end
126
317
  end
127
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.
128
323
  class AnthropicStreamResponse
129
- attr_reader :choices
130
-
131
- def initialize(fragment)
132
- parsed = JSON.parse(fragment.split('data: ').last)
133
- @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
134
334
  end
135
335
 
136
336
  def to_s
@@ -138,11 +338,14 @@ module Durable
138
338
  end
139
339
  end
140
340
 
341
+ # Represents a single choice in a streaming Anthropic response chunk.
342
+ #
343
+ # Contains the delta (incremental content) for the choice.
141
344
  class AnthropicStreamChoice
142
345
  attr_reader :delta
143
346
 
144
- def initialize(delta)
145
- @delta = AnthropicStreamDelta.new(delta)
347
+ def initialize(event)
348
+ @delta = AnthropicStreamDelta.new(event['delta'])
146
349
  end
147
350
 
148
351
  def to_s
@@ -150,6 +353,9 @@ module Durable
150
353
  end
151
354
  end
152
355
 
356
+ # Represents the incremental content delta in a streaming response.
357
+ #
358
+ # Contains the type and text content of the delta.
153
359
  class AnthropicStreamDelta
154
360
  attr_reader :type, :text
155
361
 
@@ -166,3 +372,5 @@ module Durable
166
372
  end
167
373
  end
168
374
  end
375
+
376
+ # Copyright (c) 2025 Durable Programming, LLC. All rights reserved.