ruby_llm 0.1.0.pre30 → 0.1.0.pre33

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/{gem-push.yml → cicd.yml} +32 -4
  3. data/.rspec_status +38 -0
  4. data/README.md +52 -3
  5. data/lib/ruby_llm/active_record/acts_as.rb +5 -5
  6. data/lib/ruby_llm/chat.rb +2 -2
  7. data/lib/ruby_llm/configuration.rb +5 -1
  8. data/lib/ruby_llm/content.rb +81 -0
  9. data/lib/ruby_llm/embedding.rb +9 -3
  10. data/lib/ruby_llm/image.rb +24 -0
  11. data/lib/ruby_llm/message.rb +9 -1
  12. data/lib/ruby_llm/models.json +14 -14
  13. data/lib/ruby_llm/provider.rb +57 -16
  14. data/lib/ruby_llm/providers/anthropic/capabilities.rb +81 -0
  15. data/lib/ruby_llm/providers/anthropic/chat.rb +86 -0
  16. data/lib/ruby_llm/providers/anthropic/embeddings.rb +20 -0
  17. data/lib/ruby_llm/providers/anthropic/models.rb +48 -0
  18. data/lib/ruby_llm/providers/anthropic/streaming.rb +37 -0
  19. data/lib/ruby_llm/providers/anthropic/tools.rb +97 -0
  20. data/lib/ruby_llm/providers/anthropic.rb +8 -234
  21. data/lib/ruby_llm/providers/deepseek/capabilites.rb +101 -0
  22. data/lib/ruby_llm/providers/deepseek.rb +4 -2
  23. data/lib/ruby_llm/providers/gemini/capabilities.rb +191 -0
  24. data/lib/ruby_llm/providers/gemini/models.rb +20 -0
  25. data/lib/ruby_llm/providers/gemini.rb +5 -10
  26. data/lib/ruby_llm/providers/openai/capabilities.rb +191 -0
  27. data/lib/ruby_llm/providers/openai/chat.rb +68 -0
  28. data/lib/ruby_llm/providers/openai/embeddings.rb +39 -0
  29. data/lib/ruby_llm/providers/openai/images.rb +38 -0
  30. data/lib/ruby_llm/providers/openai/media.rb +52 -0
  31. data/lib/ruby_llm/providers/openai/models.rb +40 -0
  32. data/lib/ruby_llm/providers/openai/streaming.rb +31 -0
  33. data/lib/ruby_llm/providers/openai/tools.rb +69 -0
  34. data/lib/ruby_llm/providers/openai.rb +22 -200
  35. data/lib/ruby_llm/version.rb +1 -1
  36. data/lib/ruby_llm.rb +8 -2
  37. data/ruby_llm.gemspec +7 -5
  38. metadata +57 -13
  39. data/.github/workflows/test.yml +0 -35
  40. data/lib/ruby_llm/model_capabilities/anthropic.rb +0 -79
  41. data/lib/ruby_llm/model_capabilities/deepseek.rb +0 -132
  42. data/lib/ruby_llm/model_capabilities/gemini.rb +0 -190
  43. data/lib/ruby_llm/model_capabilities/openai.rb +0 -189
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ module Anthropic
6
+ # Determines capabilities and pricing for Anthropic models
7
+ module Capabilities
8
+ module_function
9
+
10
+ def determine_context_window(model_id)
11
+ case model_id
12
+ when /claude-3/ then 200_000
13
+ else 100_000
14
+ end
15
+ end
16
+
17
+ def determine_max_tokens(model_id)
18
+ case model_id
19
+ when /claude-3-5/ then 8_192
20
+ else 4_096
21
+ end
22
+ end
23
+
24
+ def get_input_price(model_id)
25
+ PRICES.dig(model_family(model_id), :input) || default_input_price
26
+ end
27
+
28
+ def get_output_price(model_id)
29
+ PRICES.dig(model_family(model_id), :output) || default_output_price
30
+ end
31
+
32
+ def supports_vision?(model_id)
33
+ return false if model_id.match?(/claude-3-5-haiku/)
34
+ return false if model_id.match?(/claude-[12]/)
35
+
36
+ true
37
+ end
38
+
39
+ def supports_functions?(model_id)
40
+ model_id.include?('claude-3')
41
+ end
42
+
43
+ def supports_json_mode?(model_id)
44
+ model_id.include?('claude-3')
45
+ end
46
+
47
+ def model_family(model_id)
48
+ case model_id
49
+ when /claude-3-5-sonnet/ then :claude35_sonnet
50
+ when /claude-3-5-haiku/ then :claude35_haiku
51
+ when /claude-3-opus/ then :claude3_opus
52
+ when /claude-3-sonnet/ then :claude3_sonnet
53
+ when /claude-3-haiku/ then :claude3_haiku
54
+ else :claude2
55
+ end
56
+ end
57
+
58
+ def model_type(_)
59
+ 'chat'
60
+ end
61
+
62
+ PRICES = {
63
+ claude35_sonnet: { input: 3.0, output: 15.0 }, # $3.00/$15.00 per million tokens
64
+ claude35_haiku: { input: 0.80, output: 4.0 }, # $0.80/$4.00 per million tokens
65
+ claude3_opus: { input: 15.0, output: 75.0 }, # $15.00/$75.00 per million tokens
66
+ claude3_sonnet: { input: 3.0, output: 15.0 }, # $3.00/$15.00 per million tokens
67
+ claude3_haiku: { input: 0.25, output: 1.25 }, # $0.25/$1.25 per million tokens
68
+ claude2: { input: 3.0, output: 15.0 } # Default pricing for Claude 2.x models
69
+ }.freeze
70
+
71
+ def default_input_price
72
+ 3.0
73
+ end
74
+
75
+ def default_output_price
76
+ 15.0
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ module Anthropic
6
+ # Chat methods of the OpenAI API integration
7
+ module Chat
8
+ private
9
+
10
+ def completion_url
11
+ '/v1/messages'
12
+ end
13
+
14
+ def render_payload(messages, tools:, temperature:, model:, stream: false)
15
+ {
16
+ model: model,
17
+ messages: messages.map { |msg| format_message(msg) },
18
+ temperature: temperature,
19
+ stream: stream,
20
+ max_tokens: RubyLLM.models.find(model).max_tokens
21
+ }.tap do |payload|
22
+ payload[:tools] = tools.values.map { |t| function_for(t) } if tools.any?
23
+ end
24
+ end
25
+
26
+ def parse_completion_response(response)
27
+ data = response.body
28
+ content_blocks = data['content'] || []
29
+
30
+ text_content = extract_text_content(content_blocks)
31
+ tool_use = find_tool_use(content_blocks)
32
+
33
+ build_message(data, text_content, tool_use)
34
+ end
35
+
36
+ def extract_text_content(blocks)
37
+ text_blocks = blocks.select { |c| c['type'] == 'text' }
38
+ text_blocks.map { |c| c['text'] }.join('')
39
+ end
40
+
41
+ def build_message(data, content, tool_use)
42
+ Message.new(
43
+ role: :assistant,
44
+ content: content,
45
+ tool_calls: parse_tool_calls(tool_use),
46
+ input_tokens: data.dig('usage', 'input_tokens'),
47
+ output_tokens: data.dig('usage', 'output_tokens'),
48
+ model_id: data['model']
49
+ )
50
+ end
51
+
52
+ def format_message(msg)
53
+ if msg.tool_call?
54
+ format_tool_call(msg)
55
+ elsif msg.tool_result?
56
+ format_tool_result(msg)
57
+ else
58
+ format_basic_message(msg)
59
+ end
60
+ end
61
+
62
+ def format_basic_message(msg)
63
+ {
64
+ role: convert_role(msg.role),
65
+ content: msg.content
66
+ }
67
+ end
68
+
69
+ def convert_role(role)
70
+ case role
71
+ when :tool then 'user'
72
+ when :user then 'user'
73
+ else 'assistant'
74
+ end
75
+ end
76
+
77
+ def format_text_block(content)
78
+ {
79
+ type: 'text',
80
+ text: content
81
+ }
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ module Anthropic
6
+ # Embeddings methods of the Anthropic API integration
7
+ module Embeddings
8
+ private
9
+
10
+ def embed
11
+ raise Error "Anthropic doesn't support embeddings"
12
+ end
13
+
14
+ alias render_embedding_payload embed
15
+ alias embedding_url embed
16
+ alias parse_embedding_response embed
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ module Anthropic
6
+ # Models methods of the Anthropic API integration
7
+ module Models
8
+ private
9
+
10
+ def models_url
11
+ '/v1/models'
12
+ end
13
+
14
+ def parse_list_models_response(response) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
15
+ (response.body['data'] || []).map do |model|
16
+ ModelInfo.new(
17
+ id: model['id'],
18
+ created_at: Time.parse(model['created_at']),
19
+ display_name: model['display_name'],
20
+ provider: slug,
21
+ type: capabilities.model_type(model['id']),
22
+ family: capabilities.model_family(model['id']),
23
+ context_window: capabilities.determine_context_window(model['id']),
24
+ max_tokens: capabilities.determine_max_tokens(model['id']),
25
+ supports_vision: capabilities.supports_vision?(model['id']),
26
+ supports_functions: capabilities.supports_functions?(model['id']),
27
+ supports_json_mode: capabilities.supports_json_mode?(model['id']),
28
+ input_price_per_million: capabilities.get_input_price(model['id']),
29
+ output_price_per_million: capabilities.get_output_price(model['id'])
30
+ )
31
+ end
32
+ end
33
+
34
+ def extract_model_id(data)
35
+ data.dig('message', 'model')
36
+ end
37
+
38
+ def extract_input_tokens(data)
39
+ data.dig('message', 'usage', 'input_tokens')
40
+ end
41
+
42
+ def extract_output_tokens(data)
43
+ data.dig('message', 'usage', 'output_tokens') || data.dig('usage', 'output_tokens')
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ module Anthropic
6
+ # Streaming methods of the Anthropic API integration
7
+ module Streaming
8
+ private
9
+
10
+ def stream_url
11
+ completion_url
12
+ end
13
+
14
+ def handle_stream(&block)
15
+ to_json_stream do |data|
16
+ block.call(build_chunk(data))
17
+ end
18
+ end
19
+
20
+ def build_chunk(data)
21
+ Chunk.new(
22
+ role: :assistant,
23
+ model_id: extract_model_id(data),
24
+ content: data.dig('delta', 'text'),
25
+ input_tokens: extract_input_tokens(data),
26
+ output_tokens: extract_output_tokens(data),
27
+ tool_calls: extract_tool_calls(data)
28
+ )
29
+ end
30
+
31
+ def json_delta?(data)
32
+ data['type'] == 'content_block_delta' && data.dig('delta', 'type') == 'input_json_delta'
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ module Anthropic
6
+ # Tools methods of the Anthropic API integration
7
+ module Tools
8
+ private
9
+
10
+ def find_tool_use(blocks)
11
+ blocks.find { |c| c['type'] == 'tool_use' }
12
+ end
13
+
14
+ def format_tool_call(msg)
15
+ tool_call = msg.tool_calls.values.first
16
+
17
+ {
18
+ role: 'assistant',
19
+ content: [
20
+ format_text_block(msg.content),
21
+ format_tool_use_block(tool_call)
22
+ ]
23
+ }
24
+ end
25
+
26
+ def format_tool_result(msg)
27
+ {
28
+ role: 'user',
29
+ content: [format_tool_result_block(msg)]
30
+ }
31
+ end
32
+
33
+ def format_tool_use_block(tool_call)
34
+ {
35
+ type: 'tool_use',
36
+ id: tool_call.id,
37
+ name: tool_call.name,
38
+ input: tool_call.arguments
39
+ }
40
+ end
41
+
42
+ def format_tool_result_block(msg)
43
+ {
44
+ type: 'tool_result',
45
+ tool_use_id: msg.tool_call_id,
46
+ content: msg.content
47
+ }
48
+ end
49
+
50
+ def function_for(tool)
51
+ {
52
+ name: tool.name,
53
+ description: tool.description,
54
+ input_schema: {
55
+ type: 'object',
56
+ properties: clean_parameters(tool.parameters),
57
+ required: required_parameters(tool.parameters)
58
+ }
59
+ }
60
+ end
61
+
62
+ def extract_tool_calls(data)
63
+ if json_delta?(data)
64
+ { nil => ToolCall.new(id: nil, name: nil, arguments: data.dig('delta', 'partial_json')) }
65
+ else
66
+ parse_tool_calls(data['content_block'])
67
+ end
68
+ end
69
+
70
+ def parse_tool_calls(content_block)
71
+ return nil unless content_block && content_block['type'] == 'tool_use'
72
+
73
+ {
74
+ content_block['id'] => ToolCall.new(
75
+ id: content_block['id'],
76
+ name: content_block['name'],
77
+ arguments: content_block['input']
78
+ )
79
+ }
80
+ end
81
+
82
+ def clean_parameters(parameters)
83
+ parameters.transform_values do |param|
84
+ {
85
+ type: param.type,
86
+ description: param.description
87
+ }.compact
88
+ end
89
+ end
90
+
91
+ def required_parameters(parameters)
92
+ parameters.select { |_, param| param.required }.keys
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -4,17 +4,15 @@ module RubyLLM
4
4
  module Providers
5
5
  # Anthropic Claude API integration. Handles the complexities of
6
6
  # Claude's unique message format and tool calling conventions.
7
- class Anthropic # rubocop:disable Metrics/ClassLength
8
- include Provider
7
+ module Anthropic
8
+ extend Provider
9
+ extend Anthropic::Chat
10
+ extend Anthropic::Embeddings
11
+ extend Anthropic::Models
12
+ extend Anthropic::Streaming
13
+ extend Anthropic::Tools
9
14
 
10
- def parse_error(response)
11
- return if response.body.empty?
12
-
13
- body = try_parse_json(response.body)
14
- body.is_a?(Hash) ? body.dig('error', 'message') : body
15
- end
16
-
17
- private
15
+ module_function
18
16
 
19
17
  def api_base
20
18
  'https://api.anthropic.com'
@@ -26,230 +24,6 @@ module RubyLLM
26
24
  'anthropic-version' => '2023-06-01'
27
25
  }
28
26
  end
29
-
30
- def completion_url
31
- '/v1/messages'
32
- end
33
-
34
- def stream_url
35
- completion_url
36
- end
37
-
38
- def models_url
39
- '/v1/models'
40
- end
41
-
42
- def build_payload(messages, tools:, temperature:, model:, stream: false)
43
- {
44
- model: model,
45
- messages: format_messages(messages),
46
- temperature: temperature,
47
- stream: stream,
48
- max_tokens: RubyLLM.models.find(model).max_tokens
49
- }.tap do |payload|
50
- payload[:tools] = tools.values.map { |t| function_for(t) } if tools.any?
51
- end
52
- end
53
-
54
- def parse_completion_response(response)
55
- data = response.body
56
- content_blocks = data['content'] || []
57
-
58
- text_content = extract_text_content(content_blocks)
59
- tool_use = find_tool_use(content_blocks)
60
-
61
- build_message(data, text_content, tool_use)
62
- end
63
-
64
- def extract_text_content(blocks)
65
- text_blocks = blocks.select { |c| c['type'] == 'text' }
66
- text_blocks.map { |c| c['text'] }.join('')
67
- end
68
-
69
- def find_tool_use(blocks)
70
- blocks.find { |c| c['type'] == 'tool_use' }
71
- end
72
-
73
- def build_message(data, content, tool_use)
74
- Message.new(
75
- role: :assistant,
76
- content: content,
77
- tool_calls: parse_tool_calls(tool_use),
78
- input_tokens: data.dig('usage', 'input_tokens'),
79
- output_tokens: data.dig('usage', 'output_tokens'),
80
- model_id: data['model']
81
- )
82
- end
83
-
84
- def parse_list_models_response(response) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
85
- (response.body['data'] || []).map do |model|
86
- ModelInfo.new(
87
- id: model['id'],
88
- created_at: Time.parse(model['created_at']),
89
- display_name: model['display_name'],
90
- provider: slug,
91
- type: capabilities.model_type(model['id']),
92
- family: capabilities.model_family(model['id']),
93
- context_window: capabilities.determine_context_window(model['id']),
94
- max_tokens: capabilities.determine_max_tokens(model['id']),
95
- supports_vision: capabilities.supports_vision?(model['id']),
96
- supports_functions: capabilities.supports_functions?(model['id']),
97
- supports_json_mode: capabilities.supports_json_mode?(model['id']),
98
- input_price_per_million: capabilities.get_input_price(model['id']),
99
- output_price_per_million: capabilities.get_output_price(model['id'])
100
- )
101
- end
102
- end
103
-
104
- def handle_stream(&block)
105
- to_json_stream do |data|
106
- block.call(build_chunk(data))
107
- end
108
- end
109
-
110
- def build_chunk(data)
111
- Chunk.new(
112
- role: :assistant,
113
- model_id: extract_model_id(data),
114
- content: data.dig('delta', 'text'),
115
- input_tokens: extract_input_tokens(data),
116
- output_tokens: extract_output_tokens(data),
117
- tool_calls: extract_tool_calls(data)
118
- )
119
- end
120
-
121
- def extract_model_id(data)
122
- data.dig('message', 'model')
123
- end
124
-
125
- def extract_input_tokens(data)
126
- data.dig('message', 'usage', 'input_tokens')
127
- end
128
-
129
- def extract_output_tokens(data)
130
- data.dig('message', 'usage', 'output_tokens') || data.dig('usage', 'output_tokens')
131
- end
132
-
133
- def extract_tool_calls(data)
134
- if json_delta?(data)
135
- { nil => ToolCall.new(id: nil, name: nil, arguments: data.dig('delta', 'partial_json')) }
136
- else
137
- parse_tool_calls(data['content_block'])
138
- end
139
- end
140
-
141
- def json_delta?(data)
142
- data['type'] == 'content_block_delta' && data.dig('delta', 'type') == 'input_json_delta'
143
- end
144
-
145
- def parse_tool_calls(content_block)
146
- return nil unless content_block && content_block['type'] == 'tool_use'
147
-
148
- {
149
- content_block['id'] => ToolCall.new(
150
- id: content_block['id'],
151
- name: content_block['name'],
152
- arguments: content_block['input']
153
- )
154
- }
155
- end
156
-
157
- def function_for(tool)
158
- {
159
- name: tool.name,
160
- description: tool.description,
161
- input_schema: {
162
- type: 'object',
163
- properties: clean_parameters(tool.parameters),
164
- required: required_parameters(tool.parameters)
165
- }
166
- }
167
- end
168
-
169
- def format_messages(messages)
170
- messages.map { |msg| format_message(msg) }
171
- end
172
-
173
- def format_message(msg)
174
- if msg.tool_call?
175
- format_tool_call(msg)
176
- elsif msg.tool_result?
177
- format_tool_result(msg)
178
- else
179
- format_basic_message(msg)
180
- end
181
- end
182
-
183
- def format_tool_call(msg)
184
- tool_call = msg.tool_calls.values.first
185
-
186
- {
187
- role: 'assistant',
188
- content: [
189
- format_text_block(msg.content),
190
- format_tool_use_block(tool_call)
191
- ]
192
- }
193
- end
194
-
195
- def format_tool_result(msg)
196
- {
197
- role: 'user',
198
- content: [format_tool_result_block(msg)]
199
- }
200
- end
201
-
202
- def format_basic_message(msg)
203
- {
204
- role: convert_role(msg.role),
205
- content: msg.content
206
- }
207
- end
208
-
209
- def format_text_block(content)
210
- {
211
- type: 'text',
212
- text: content
213
- }
214
- end
215
-
216
- def format_tool_use_block(tool_call)
217
- {
218
- type: 'tool_use',
219
- id: tool_call.id,
220
- name: tool_call.name,
221
- input: tool_call.arguments
222
- }
223
- end
224
-
225
- def format_tool_result_block(msg)
226
- {
227
- type: 'tool_result',
228
- tool_use_id: msg.tool_call_id,
229
- content: msg.content
230
- }
231
- end
232
-
233
- def convert_role(role)
234
- case role
235
- when :tool then 'user'
236
- when :user then 'user'
237
- else 'assistant'
238
- end
239
- end
240
-
241
- def clean_parameters(parameters)
242
- parameters.transform_values do |param|
243
- {
244
- type: param.type,
245
- description: param.description
246
- }.compact
247
- end
248
- end
249
-
250
- def required_parameters(parameters)
251
- parameters.select { |_, param| param.required }.keys
252
- end
253
27
  end
254
28
  end
255
29
  end