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.
- checksums.yaml +4 -4
- data/.envrc +7 -0
- data/CHANGELOG.md +5 -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 +215 -22
- data/lib/durable/llm/client.rb +85 -6
- data/lib/durable/llm/configuration.rb +163 -10
- data/lib/durable/llm/errors.rb +185 -0
- data/lib/durable/llm/providers/anthropic.rb +232 -24
- data/lib/durable/llm/providers/azure_openai.rb +347 -0
- data/lib/durable/llm/providers/base.rb +83 -1
- data/lib/durable/llm/providers/cohere.rb +138 -11
- 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 +107 -25
- data/lib/durable/llm/providers/huggingface.rb +120 -17
- data/lib/durable/llm/providers/mistral.rb +431 -0
- data/lib/durable/llm/providers/openai.rb +150 -4
- 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 -13
- data/lib/durable/llm/version.rb +5 -1
- data/lib/durable/llm.rb +141 -1
- data/lib/durable.rb +29 -4
- data/sig/durable/llm.rbs +302 -1
- metadata +48 -36
@@ -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 =
|
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
|
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
|
-
|
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
|
237
|
+
raise Durable::Llm::AuthenticationError, parse_error_message(response)
|
76
238
|
when 429
|
77
|
-
raise Durable::Llm::RateLimitError, response
|
239
|
+
raise Durable::Llm::RateLimitError, parse_error_message(response)
|
78
240
|
when 400..499
|
79
|
-
raise Durable::Llm::InvalidRequestError, response
|
241
|
+
raise Durable::Llm::InvalidRequestError, parse_error_message(response)
|
80
242
|
when 500..599
|
81
|
-
raise Durable::Llm::ServerError, response
|
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
|
-
[
|
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(
|
107
|
-
@message = AnthropicMessage.new(
|
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(
|
119
|
-
@role = [
|
120
|
-
@content = [content]
|
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(
|
132
|
-
|
133
|
-
@choices =
|
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(
|
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.
|