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,227 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This file implements the Cohere provider for accessing Cohere's language models through their API.
|
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
|
+
# 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.
|
18
|
+
class Cohere < Durable::Llm::Providers::Base
|
19
|
+
BASE_URL = 'https://api.cohere.ai/v2'
|
20
|
+
|
21
|
+
def default_api_key
|
22
|
+
Durable::Llm.configuration.cohere&.api_key || ENV['COHERE_API_KEY']
|
23
|
+
end
|
24
|
+
|
25
|
+
attr_accessor :api_key
|
26
|
+
|
27
|
+
def initialize(api_key: nil)
|
28
|
+
super(api_key: api_key)
|
29
|
+
@conn = Faraday.new(url: BASE_URL) do |faraday|
|
30
|
+
faraday.request :json
|
31
|
+
faraday.response :json
|
32
|
+
faraday.adapter Faraday.default_adapter
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def completion(options)
|
37
|
+
response = @conn.post('chat') do |req|
|
38
|
+
req.headers['Authorization'] = "Bearer #{@api_key}"
|
39
|
+
req.headers['Content-Type'] = 'application/json'
|
40
|
+
req.body = options
|
41
|
+
end
|
42
|
+
|
43
|
+
handle_response(response)
|
44
|
+
end
|
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
|
+
|
74
|
+
def models
|
75
|
+
response = @conn.get('../v1/models') do |req|
|
76
|
+
req.headers['Authorization'] = "Bearer #{@api_key}"
|
77
|
+
end
|
78
|
+
|
79
|
+
data = handle_response(response).raw_response
|
80
|
+
data['models']&.map { |model| model['name'] }
|
81
|
+
end
|
82
|
+
|
83
|
+
def self.stream?
|
84
|
+
true
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
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)
|
121
|
+
case response.status
|
122
|
+
when 200..299
|
123
|
+
response_class.new(response.body)
|
124
|
+
when 401
|
125
|
+
raise Durable::Llm::AuthenticationError, parse_error_message(response)
|
126
|
+
when 429
|
127
|
+
raise Durable::Llm::RateLimitError, parse_error_message(response)
|
128
|
+
when 400..499
|
129
|
+
raise Durable::Llm::InvalidRequestError, parse_error_message(response)
|
130
|
+
when 500..599
|
131
|
+
raise Durable::Llm::ServerError, parse_error_message(response)
|
132
|
+
else
|
133
|
+
raise Durable::Llm::APIError, "Unexpected response code: #{response.status}"
|
134
|
+
end
|
135
|
+
end
|
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
|
+
|
147
|
+
class CohereResponse
|
148
|
+
attr_reader :raw_response
|
149
|
+
|
150
|
+
def initialize(response)
|
151
|
+
@raw_response = response
|
152
|
+
end
|
153
|
+
|
154
|
+
def choices
|
155
|
+
@raw_response.dig('message', 'content')&.map { |generation| CohereChoice.new(generation) } || []
|
156
|
+
end
|
157
|
+
|
158
|
+
def to_s
|
159
|
+
choices.map(&:to_s).join(' ')
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
class CohereChoice
|
164
|
+
attr_reader :text
|
165
|
+
|
166
|
+
def initialize(generation)
|
167
|
+
@text = generation['text']
|
168
|
+
end
|
169
|
+
|
170
|
+
def to_s
|
171
|
+
@text
|
172
|
+
end
|
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
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
# Copyright (c) 2025 Durable Programming, LLC. All rights reserved.
|
@@ -0,0 +1,233 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# DeepSeek provider for language model API access with completion, embedding, and streaming support.
|
4
|
+
|
5
|
+
require 'faraday'
|
6
|
+
require 'json'
|
7
|
+
require 'event_stream_parser'
|
8
|
+
require 'durable/llm/errors'
|
9
|
+
require 'durable/llm/providers/base'
|
10
|
+
|
11
|
+
module Durable
|
12
|
+
module Llm
|
13
|
+
module Providers
|
14
|
+
# DeepSeek provider for language model API interactions
|
15
|
+
class DeepSeek < Durable::Llm::Providers::Base
|
16
|
+
BASE_URL = 'https://api.deepseek.com'
|
17
|
+
|
18
|
+
def default_api_key
|
19
|
+
Durable::Llm.configuration.deepseek&.api_key || ENV['DEEPSEEK_API_KEY']
|
20
|
+
end
|
21
|
+
|
22
|
+
attr_accessor :api_key
|
23
|
+
|
24
|
+
def initialize(api_key: nil)
|
25
|
+
super()
|
26
|
+
@api_key = api_key || default_api_key
|
27
|
+
@conn = Faraday.new(url: BASE_URL) do |faraday|
|
28
|
+
faraday.request :json
|
29
|
+
faraday.response :json
|
30
|
+
faraday.adapter Faraday.default_adapter
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def completion(options)
|
35
|
+
response = @conn.post('chat/completions') do |req|
|
36
|
+
req.headers['Authorization'] = "Bearer #{@api_key}"
|
37
|
+
req.body = options
|
38
|
+
end
|
39
|
+
|
40
|
+
handle_response(response)
|
41
|
+
end
|
42
|
+
|
43
|
+
def embedding(model:, input:, **options)
|
44
|
+
response = @conn.post('embeddings') do |req|
|
45
|
+
req.headers['Authorization'] = "Bearer #{@api_key}"
|
46
|
+
req.body = { model: model, input: input, **options }
|
47
|
+
end
|
48
|
+
|
49
|
+
handle_response(response, DeepSeekEmbeddingResponse)
|
50
|
+
end
|
51
|
+
|
52
|
+
def models
|
53
|
+
response = @conn.get('models') do |req|
|
54
|
+
req.headers['Authorization'] = "Bearer #{@api_key}"
|
55
|
+
end
|
56
|
+
|
57
|
+
handle_response(response).data.map { |model| model['id'] }
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.stream?
|
61
|
+
true
|
62
|
+
end
|
63
|
+
|
64
|
+
def stream(options)
|
65
|
+
opts = options.dup
|
66
|
+
opts[:stream] = true
|
67
|
+
opts['temperature'] = opts['temperature'].to_f if opts['temperature']
|
68
|
+
|
69
|
+
@conn.post('chat/completions') do |req|
|
70
|
+
req.headers['Authorization'] = "Bearer #{@api_key}"
|
71
|
+
req.headers['Accept'] = 'text/event-stream'
|
72
|
+
req.body = opts
|
73
|
+
req.options.on_data = to_json_stream(user_proc: proc { |chunk| yield DeepSeekStreamResponse.new(chunk) })
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
# CODE-FROM: ruby-openai @ https://github.com/alexrudall/ruby-openai/blob/main/lib/openai/http.rb
|
80
|
+
# MIT License: https://github.com/alexrudall/ruby-openai/blob/main/LICENSE.md
|
81
|
+
def to_json_stream(user_proc:)
|
82
|
+
parser = EventStreamParser::Parser.new
|
83
|
+
|
84
|
+
proc do |chunk, _bytes, env|
|
85
|
+
if env && env.status != 200
|
86
|
+
raise_error = Faraday::Response::RaiseError.new
|
87
|
+
raise_error.on_complete(env.merge(body: try_parse_json(chunk)))
|
88
|
+
end
|
89
|
+
|
90
|
+
parser.feed(chunk) do |_type, data|
|
91
|
+
user_proc.call(JSON.parse(data)) unless data == '[DONE]'
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def try_parse_json(maybe_json)
|
97
|
+
JSON.parse(maybe_json)
|
98
|
+
rescue JSON::ParserError
|
99
|
+
maybe_json
|
100
|
+
end
|
101
|
+
|
102
|
+
# END-CODE-FROM
|
103
|
+
|
104
|
+
def handle_response(response, response_class = DeepSeekResponse)
|
105
|
+
case response.status
|
106
|
+
when 200..299 then response_class.new(response.body)
|
107
|
+
when 401 then raise Durable::Llm::AuthenticationError, parse_error_message(response)
|
108
|
+
when 429 then raise Durable::Llm::RateLimitError, parse_error_message(response)
|
109
|
+
when 400..499 then raise Durable::Llm::InvalidRequestError, parse_error_message(response)
|
110
|
+
when 500..599 then raise Durable::Llm::ServerError, parse_error_message(response)
|
111
|
+
else raise Durable::Llm::APIError, "Unexpected response code: #{response.status}"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def parse_error_message(response)
|
116
|
+
body = begin
|
117
|
+
JSON.parse(response.body)
|
118
|
+
rescue StandardError
|
119
|
+
nil
|
120
|
+
end
|
121
|
+
message = body&.dig('error', 'message') || response.body
|
122
|
+
"#{response.status} Error: #{message}"
|
123
|
+
end
|
124
|
+
|
125
|
+
# Response wrapper for DeepSeek API responses
|
126
|
+
class DeepSeekResponse
|
127
|
+
attr_reader :raw_response
|
128
|
+
|
129
|
+
def initialize(response)
|
130
|
+
@raw_response = response
|
131
|
+
end
|
132
|
+
|
133
|
+
def choices
|
134
|
+
@raw_response['choices'].map { |choice| DeepSeekChoice.new(choice) }
|
135
|
+
end
|
136
|
+
|
137
|
+
def data
|
138
|
+
@raw_response['data']
|
139
|
+
end
|
140
|
+
|
141
|
+
def to_s
|
142
|
+
choices.map(&:to_s).join(' ')
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
# Choice wrapper for DeepSeek response choices
|
147
|
+
class DeepSeekChoice
|
148
|
+
attr_reader :message, :finish_reason
|
149
|
+
|
150
|
+
def initialize(choice)
|
151
|
+
@message = DeepSeekMessage.new(choice['message'])
|
152
|
+
@finish_reason = choice['finish_reason']
|
153
|
+
end
|
154
|
+
|
155
|
+
def to_s
|
156
|
+
@message.to_s
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
# Message wrapper for DeepSeek messages
|
161
|
+
class DeepSeekMessage
|
162
|
+
attr_reader :role, :content
|
163
|
+
|
164
|
+
def initialize(message)
|
165
|
+
@role = message['role']
|
166
|
+
@content = message['content']
|
167
|
+
end
|
168
|
+
|
169
|
+
def to_s
|
170
|
+
@content
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
# Stream response wrapper for DeepSeek streaming
|
175
|
+
class DeepSeekStreamResponse
|
176
|
+
attr_reader :choices
|
177
|
+
|
178
|
+
def initialize(parsed)
|
179
|
+
@choices = DeepSeekStreamChoice.new(parsed['choices'])
|
180
|
+
end
|
181
|
+
|
182
|
+
def to_s
|
183
|
+
@choices.to_s
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# Embedding response wrapper for DeepSeek embeddings
|
188
|
+
class DeepSeekEmbeddingResponse
|
189
|
+
attr_reader :embedding
|
190
|
+
|
191
|
+
def initialize(data)
|
192
|
+
@embedding = data.dig('data', 0, 'embedding')
|
193
|
+
end
|
194
|
+
|
195
|
+
def to_a
|
196
|
+
@embedding
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
# Stream choice wrapper for DeepSeek streaming
|
201
|
+
class DeepSeekStreamChoice
|
202
|
+
attr_reader :delta, :finish_reason
|
203
|
+
|
204
|
+
def initialize(choice)
|
205
|
+
@choice = [choice].flatten.first
|
206
|
+
@delta = DeepSeekStreamDelta.new(@choice['delta'])
|
207
|
+
@finish_reason = @choice['finish_reason']
|
208
|
+
end
|
209
|
+
|
210
|
+
def to_s
|
211
|
+
@delta.to_s
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# Stream delta wrapper for DeepSeek streaming
|
216
|
+
class DeepSeekStreamDelta
|
217
|
+
attr_reader :role, :content
|
218
|
+
|
219
|
+
def initialize(delta)
|
220
|
+
@role = delta['role']
|
221
|
+
@content = delta['content']
|
222
|
+
end
|
223
|
+
|
224
|
+
def to_s
|
225
|
+
@content || ''
|
226
|
+
end
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
# Copyright (c) 2025 Durable Programming, LLC. All rights reserved.
|