ruby_llm 0.1.0.pre30 → 0.1.0.pre31

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/.github/workflows/{gem-push.yml → cicd.yml} +32 -4
  3. data/.rspec_status +27 -0
  4. data/lib/ruby_llm/active_record/acts_as.rb +5 -5
  5. data/lib/ruby_llm/chat.rb +2 -2
  6. data/lib/ruby_llm/configuration.rb +3 -1
  7. data/lib/ruby_llm/content.rb +79 -0
  8. data/lib/ruby_llm/embedding.rb +9 -3
  9. data/lib/ruby_llm/message.rb +9 -1
  10. data/lib/ruby_llm/models.json +14 -14
  11. data/lib/ruby_llm/provider.rb +39 -14
  12. data/lib/ruby_llm/providers/anthropic/capabilities.rb +81 -0
  13. data/lib/ruby_llm/providers/anthropic/chat.rb +86 -0
  14. data/lib/ruby_llm/providers/anthropic/embeddings.rb +20 -0
  15. data/lib/ruby_llm/providers/anthropic/models.rb +48 -0
  16. data/lib/ruby_llm/providers/anthropic/streaming.rb +37 -0
  17. data/lib/ruby_llm/providers/anthropic/tools.rb +97 -0
  18. data/lib/ruby_llm/providers/anthropic.rb +8 -234
  19. data/lib/ruby_llm/providers/deepseek/capabilites.rb +101 -0
  20. data/lib/ruby_llm/providers/deepseek.rb +4 -2
  21. data/lib/ruby_llm/providers/gemini/capabilities.rb +191 -0
  22. data/lib/ruby_llm/providers/gemini/models.rb +20 -0
  23. data/lib/ruby_llm/providers/gemini.rb +5 -10
  24. data/lib/ruby_llm/providers/openai/capabilities.rb +191 -0
  25. data/lib/ruby_llm/providers/openai/chat.rb +68 -0
  26. data/lib/ruby_llm/providers/openai/embeddings.rb +39 -0
  27. data/lib/ruby_llm/providers/openai/models.rb +40 -0
  28. data/lib/ruby_llm/providers/openai/streaming.rb +31 -0
  29. data/lib/ruby_llm/providers/openai/tools.rb +69 -0
  30. data/lib/ruby_llm/providers/openai.rb +15 -197
  31. data/lib/ruby_llm/version.rb +1 -1
  32. data/lib/ruby_llm.rb +4 -2
  33. data/ruby_llm.gemspec +2 -0
  34. metadata +48 -8
  35. data/.github/workflows/test.yml +0 -35
  36. data/lib/ruby_llm/model_capabilities/anthropic.rb +0 -79
  37. data/lib/ruby_llm/model_capabilities/deepseek.rb +0 -132
  38. data/lib/ruby_llm/model_capabilities/gemini.rb +0 -190
  39. data/lib/ruby_llm/model_capabilities/openai.rb +0 -189
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ module Gemini
6
+ # Determines capabilities and pricing for Google Gemini models
7
+ module Capabilities # rubocop:disable Metrics/ModuleLength
8
+ module_function
9
+
10
+ def context_window_for(model_id)
11
+ case model_id
12
+ when /gemini-2\.0-flash/, /gemini-1\.5-flash/ then 1_048_576
13
+ when /gemini-1\.5-pro/ then 2_097_152
14
+ when /text-embedding/, /embedding-001/ then 2_048
15
+ when /aqa/ then 7_168
16
+ else 32_768 # Sensible default for unknown models
17
+ end
18
+ end
19
+
20
+ def max_tokens_for(model_id)
21
+ case model_id
22
+ when /gemini-2\.0-flash/, /gemini-1\.5/ then 8_192
23
+ when /text-embedding/, /embedding-001/ then 768 # Output dimension size for embeddings
24
+ when /aqa/ then 1_024
25
+ else 4_096 # Sensible default
26
+ end
27
+ end
28
+
29
+ def input_price_for(model_id)
30
+ base_price = PRICES.dig(pricing_family(model_id), :input) || default_input_price
31
+ return base_price unless long_context_model?(model_id)
32
+
33
+ # Double the price for prompts longer than 128k tokens
34
+ context_length(model_id) > 128_000 ? base_price * 2 : base_price
35
+ end
36
+
37
+ def output_price_for(model_id)
38
+ base_price = PRICES.dig(pricing_family(model_id), :output) || default_output_price
39
+ return base_price unless long_context_model?(model_id)
40
+
41
+ # Double the price for prompts longer than 128k tokens
42
+ context_length(model_id) > 128_000 ? base_price * 2 : base_price
43
+ end
44
+
45
+ def supports_vision?(model_id)
46
+ return false if model_id.match?(/text-embedding|embedding-001|aqa/)
47
+ return false if model_id.match?(/gemini-1\.0/)
48
+
49
+ model_id.match?(/gemini-[12]\.[05]/)
50
+ end
51
+
52
+ def supports_functions?(model_id)
53
+ return false if model_id.match?(/text-embedding|embedding-001|aqa/)
54
+ return false if model_id.match?(/flash-lite/)
55
+ return false if model_id.match?(/gemini-1\.0/)
56
+
57
+ model_id.match?(/gemini-[12]\.[05]-(?:pro|flash)(?!-lite)/)
58
+ end
59
+
60
+ def supports_json_mode?(model_id)
61
+ return false if model_id.match?(/text-embedding|embedding-001|aqa/)
62
+ return false if model_id.match?(/gemini-1\.0/)
63
+
64
+ model_id.match?(/gemini-\d/)
65
+ end
66
+
67
+ def format_display_name(model_id)
68
+ model_id
69
+ .delete_prefix('models/')
70
+ .split('-')
71
+ .map(&:capitalize)
72
+ .join(' ')
73
+ .gsub(/(\d+\.\d+)/, ' \1') # Add space before version numbers
74
+ .gsub(/\s+/, ' ') # Clean up multiple spaces
75
+ .gsub(/Aqa/, 'AQA') # Special case for AQA
76
+ .strip
77
+ end
78
+
79
+ def supports_caching?(model_id)
80
+ return false if model_id.match?(/flash-lite|gemini-1\.0/)
81
+
82
+ model_id.match?(/gemini-[12]\.[05]/)
83
+ end
84
+
85
+ def supports_tuning?(model_id)
86
+ model_id.match?(/gemini-1\.5-flash/)
87
+ end
88
+
89
+ def supports_audio?(model_id)
90
+ model_id.match?(/gemini-[12]\.[05]/)
91
+ end
92
+
93
+ def model_type(model_id)
94
+ case model_id
95
+ when /text-embedding|embedding/ then 'embedding'
96
+ when /imagen/ then 'image'
97
+ else 'chat'
98
+ end
99
+ end
100
+
101
+ def model_family(model_id) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
102
+ case model_id
103
+ when /gemini-2\.0-flash-lite/ then 'gemini20_flash_lite'
104
+ when /gemini-2\.0-flash/ then 'gemini20_flash'
105
+ when /gemini-1\.5-flash-8b/ then 'gemini15_flash_8b'
106
+ when /gemini-1\.5-flash/ then 'gemini15_flash'
107
+ when /gemini-1\.5-pro/ then 'gemini15_pro'
108
+ when /gemini-1\.0-pro/ then 'gemini10_pro'
109
+ when /text-embedding-004/ then 'embedding4'
110
+ when /embedding-001/ then 'embedding1'
111
+ when /aqa/ then 'aqa'
112
+ else 'other'
113
+ end
114
+ end
115
+
116
+ def pricing_family(model_id) # rubocop:disable Metrics/CyclomaticComplexity
117
+ case model_id
118
+ when /gemini-2\.0-flash-lite/ then :flash_lite_2 # rubocop:disable Naming/VariableNumber
119
+ when /gemini-2\.0-flash/ then :flash_2 # rubocop:disable Naming/VariableNumber
120
+ when /gemini-1\.5-flash-8b/ then :flash_8b
121
+ when /gemini-1\.5-flash/ then :flash
122
+ when /gemini-1\.5-pro/ then :pro
123
+ when /gemini-1\.0-pro/ then :pro_1_0 # rubocop:disable Naming/VariableNumber
124
+ when /text-embedding|embedding/ then :embedding
125
+ else :base
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def long_context_model?(model_id)
132
+ model_id.match?(/gemini-1\.5-(?:pro|flash)/)
133
+ end
134
+
135
+ def context_length(model_id)
136
+ context_window_for(model_id)
137
+ end
138
+
139
+ PRICES = {
140
+ flash_2: { # Gemini 2.0 Flash # rubocop:disable Naming/VariableNumber
141
+ input: 0.10,
142
+ output: 0.40,
143
+ audio_input: 0.70,
144
+ cache: 0.025,
145
+ cache_storage: 1.00
146
+ },
147
+ flash_lite_2: { # Gemini 2.0 Flash Lite # rubocop:disable Naming/VariableNumber
148
+ input: 0.075,
149
+ output: 0.30,
150
+ cache: 0.01875,
151
+ cache_storage: 1.00
152
+ },
153
+ flash: { # Gemini 1.5 Flash
154
+ input: 0.075,
155
+ output: 0.30,
156
+ cache: 0.01875,
157
+ cache_storage: 1.00
158
+ },
159
+ flash_8b: { # Gemini 1.5 Flash 8B
160
+ input: 0.0375,
161
+ output: 0.15,
162
+ cache: 0.01,
163
+ cache_storage: 0.25
164
+ },
165
+ pro: { # Gemini 1.5 Pro
166
+ input: 1.25,
167
+ output: 5.0,
168
+ cache: 0.3125,
169
+ cache_storage: 4.50
170
+ },
171
+ pro_1_0: { # Gemini 1.0 Pro # rubocop:disable Naming/VariableNumber
172
+ input: 0.50,
173
+ output: 1.50
174
+ },
175
+ embedding: { # Text Embedding models
176
+ input: 0.00,
177
+ output: 0.00
178
+ }
179
+ }.freeze
180
+
181
+ def default_input_price
182
+ 0.075 # Default to Flash pricing
183
+ end
184
+
185
+ def default_output_price
186
+ 0.30 # Default to Flash pricing
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ module Gemini
6
+ # Models methods of the Gemini API integration
7
+ module Models
8
+ module_function
9
+
10
+ def parse_list_models_response(response)
11
+ response.body['data']&.each do |model|
12
+ model['id'] = model['id'].delete_prefix('models/')
13
+ end
14
+
15
+ OpenAI::Models.parse_list_models_response(response)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -3,8 +3,11 @@
3
3
  module RubyLLM
4
4
  module Providers
5
5
  # Gemini API integration.
6
- class Gemini < OpenAI
7
- private
6
+ module Gemini
7
+ extend OpenAI
8
+ extend Gemini::Models
9
+
10
+ module_function
8
11
 
9
12
  def api_base
10
13
  'https://generativelanguage.googleapis.com/v1beta/openai'
@@ -15,14 +18,6 @@ module RubyLLM
15
18
  'Authorization' => "Bearer #{RubyLLM.config.gemini_api_key}"
16
19
  }
17
20
  end
18
-
19
- def parse_list_models_response(response)
20
- response.body['data']&.each do |model|
21
- model['id'] = model['id'].delete_prefix('models/')
22
- end
23
-
24
- super(response)
25
- end
26
21
  end
27
22
  end
28
23
  end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ module OpenAI
6
+ # Determines capabilities and pricing for OpenAI models
7
+ module Capabilities # rubocop:disable Metrics/ModuleLength
8
+ module_function
9
+
10
+ def context_window_for(model_id)
11
+ case model_id
12
+ when /o[13]-mini/, /o3-mini-2025/ then 200_000
13
+ when /o1-2024/ then 200_000
14
+ when /gpt-4o/, /gpt-4-turbo/ then 128_000
15
+ when /gpt-4-0[0-9]{3}/ then 8_192
16
+ when /gpt-3.5-turbo-instruct/ then 4_096
17
+ when /gpt-3.5/ then 16_385
18
+ else 4_096
19
+ end
20
+ end
21
+
22
+ def max_tokens_for(model_id) # rubocop:disable Metrics/CyclomaticComplexity
23
+ case model_id
24
+ when /o1-2024/, /o3-mini/ then 100_000
25
+ when /o1-mini-2024/ then 65_536
26
+ when /gpt-4o-2024-05-13/ then 4_096
27
+ when /gpt-4o/, /gpt-4o-mini/ then 16_384
28
+ when /gpt-4o-realtime/ then 4_096
29
+ when /gpt-4-0[0-9]{3}/ then 8_192
30
+ when /gpt-3.5-turbo/ then 4_096
31
+ else 4_096
32
+ end
33
+ end
34
+
35
+ def input_price_for(model_id)
36
+ PRICES.dig(model_family(model_id), :input) || default_input_price
37
+ end
38
+
39
+ def output_price_for(model_id)
40
+ PRICES.dig(model_family(model_id), :output) || default_output_price
41
+ end
42
+
43
+ def supports_vision?(model_id)
44
+ model_id.match?(/gpt-4o|o1/) || model_id.match?(/gpt-4-(?!0314|0613)/)
45
+ end
46
+
47
+ def supports_functions?(model_id)
48
+ !model_id.include?('instruct')
49
+ end
50
+
51
+ def supports_audio?(model_id)
52
+ model_id.match?(/audio-preview|realtime-preview|whisper|tts/)
53
+ end
54
+
55
+ def supports_json_mode?(model_id)
56
+ model_id.match?(/gpt-4-\d{4}-preview/) ||
57
+ model_id.include?('turbo') ||
58
+ model_id.match?(/gpt-3.5-turbo-(?!0301|0613)/)
59
+ end
60
+
61
+ def format_display_name(model_id)
62
+ model_id.then { |id| humanize(id) }
63
+ .then { |name| apply_special_formatting(name) }
64
+ end
65
+
66
+ def model_type(model_id)
67
+ case model_id
68
+ when /text-embedding|embedding/ then 'embedding'
69
+ when /dall-e/ then 'image'
70
+ when /tts|whisper/ then 'audio'
71
+ when /omni-moderation/ then 'moderation'
72
+ else 'chat'
73
+ end
74
+ end
75
+
76
+ def supports_structured_output?(model_id)
77
+ model_id.match?(/gpt-4o|o[13]-mini|o1/)
78
+ end
79
+
80
+ def model_family(model_id) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength
81
+ case model_id
82
+ when /o3-mini/ then 'o3_mini'
83
+ when /o1-mini/ then 'o1_mini'
84
+ when /o1/ then 'o1'
85
+ when /gpt-4o-audio/ then 'gpt4o_audio'
86
+ when /gpt-4o-realtime/ then 'gpt4o_realtime'
87
+ when /gpt-4o-mini-audio/ then 'gpt4o_mini_audio'
88
+ when /gpt-4o-mini-realtime/ then 'gpt4o_mini_realtime'
89
+ when /gpt-4o-mini/ then 'gpt4o_mini'
90
+ when /gpt-4o/ then 'gpt4o'
91
+ when /gpt-4-turbo/ then 'gpt4_turbo'
92
+ when /gpt-4/ then 'gpt4'
93
+ when /gpt-3.5-turbo-instruct/ then 'gpt35_instruct'
94
+ when /gpt-3.5/ then 'gpt35'
95
+ when /dall-e-3/ then 'dalle3'
96
+ when /dall-e-2/ then 'dalle2'
97
+ when /text-embedding-3-large/ then 'embedding3_large'
98
+ when /text-embedding-3-small/ then 'embedding3_small'
99
+ when /text-embedding-ada/ then 'embedding2'
100
+ when /tts-1-hd/ then 'tts1_hd'
101
+ when /tts-1/ then 'tts1'
102
+ when /whisper/ then 'whisper1'
103
+ when /omni-moderation/ then 'moderation'
104
+ when /babbage/ then 'babbage'
105
+ when /davinci/ then 'davinci'
106
+ else 'other'
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ PRICES = {
113
+ o1: { input: 15.0, cached_input: 7.5, output: 60.0 },
114
+ o1_mini: { input: 1.10, cached_input: 0.55, output: 4.40 },
115
+ o3_mini: { input: 1.10, cached_input: 0.55, output: 4.40 },
116
+ gpt4o: { input: 2.50, cached_input: 1.25, output: 10.0 },
117
+ gpt4o_audio: {
118
+ text_input: 2.50,
119
+ audio_input: 40.0,
120
+ text_output: 10.0,
121
+ audio_output: 80.0
122
+ },
123
+ gpt4o_realtime: {
124
+ text_input: 5.0,
125
+ cached_text_input: 2.50,
126
+ audio_input: 40.0,
127
+ cached_audio_input: 2.50,
128
+ text_output: 20.0,
129
+ audio_output: 80.0
130
+ },
131
+ gpt4o_mini: { input: 0.15, cached_input: 0.075, output: 0.60 },
132
+ gpt4o_mini_audio: {
133
+ text_input: 0.15,
134
+ audio_input: 10.0,
135
+ text_output: 0.60,
136
+ audio_output: 20.0
137
+ },
138
+ gpt4o_mini_realtime: {
139
+ text_input: 0.60,
140
+ cached_text_input: 0.30,
141
+ audio_input: 10.0,
142
+ cached_audio_input: 0.30,
143
+ text_output: 2.40,
144
+ audio_output: 20.0
145
+ },
146
+ gpt4_turbo: { input: 10.0, output: 30.0 },
147
+ gpt4: { input: 30.0, output: 60.0 },
148
+ gpt35: { input: 0.50, output: 1.50 },
149
+ gpt35_instruct: { input: 1.50, output: 2.0 },
150
+ embedding3_large: { price: 0.13 },
151
+ embedding3_small: { price: 0.02 },
152
+ embedding2: { price: 0.10 },
153
+ davinci: { input: 2.0, output: 2.0 },
154
+ babbage: { input: 0.40, output: 0.40 },
155
+ tts1: { price: 15.0 },
156
+ tts1_hd: { price: 30.0 },
157
+ whisper1: { price: 0.006 }
158
+ }.freeze
159
+
160
+ def default_input_price
161
+ 0.50
162
+ end
163
+
164
+ def default_output_price
165
+ 1.50
166
+ end
167
+
168
+ def humanize(id)
169
+ id.tr('-', ' ')
170
+ .split(' ')
171
+ .map(&:capitalize)
172
+ .join(' ')
173
+ end
174
+
175
+ def apply_special_formatting(name) # rubocop:disable Metrics/MethodLength
176
+ name
177
+ .gsub(/(\d{4}) (\d{2}) (\d{2})/, '\1\2\3')
178
+ .gsub(/^Gpt /, 'GPT-')
179
+ .gsub(/^O([13]) /, 'O\1-')
180
+ .gsub(/^Chatgpt /, 'ChatGPT-')
181
+ .gsub(/^Tts /, 'TTS-')
182
+ .gsub(/^Dall E /, 'DALL-E-')
183
+ .gsub(/3\.5 /, '3.5-')
184
+ .gsub(/4 /, '4-')
185
+ .gsub(/4o (?=Mini|Preview|Turbo|Audio)/, '4o-')
186
+ .gsub(/\bHd\b/, 'HD')
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ module OpenAI
6
+ # Chat methods of the OpenAI API integration
7
+ module Chat
8
+ module_function
9
+
10
+ def completion_url
11
+ 'chat/completions'
12
+ end
13
+
14
+ def render_payload(messages, tools:, temperature:, model:, stream: false) # rubocop:disable Metrics/MethodLength
15
+ {
16
+ model: model,
17
+ messages: format_messages(messages),
18
+ temperature: temperature,
19
+ stream: stream
20
+ }.tap do |payload|
21
+ if tools.any?
22
+ payload[:tools] = tools.map { |_, tool| tool_for(tool) }
23
+ payload[:tool_choice] = 'auto'
24
+ end
25
+ payload[:stream_options] = { include_usage: true } if stream
26
+ end
27
+ end
28
+
29
+ def parse_completion_response(response) # rubocop:disable Metrics/MethodLength
30
+ data = response.body
31
+ return if data.empty?
32
+
33
+ message_data = data.dig('choices', 0, 'message')
34
+ return unless message_data
35
+
36
+ Message.new(
37
+ role: :assistant,
38
+ content: message_data['content'],
39
+ tool_calls: parse_tool_calls(message_data['tool_calls']),
40
+ input_tokens: data['usage']['prompt_tokens'],
41
+ output_tokens: data['usage']['completion_tokens'],
42
+ model_id: data['model']
43
+ )
44
+ end
45
+
46
+ def format_messages(messages)
47
+ messages.map do |msg|
48
+ {
49
+ role: format_role(msg.role),
50
+ content: msg.content,
51
+ tool_calls: format_tool_calls(msg.tool_calls),
52
+ tool_call_id: msg.tool_call_id
53
+ }.compact
54
+ end
55
+ end
56
+
57
+ def format_role(role)
58
+ case role
59
+ when :system
60
+ 'developer'
61
+ else
62
+ role.to_s
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ module OpenAI
6
+ # Embeddings methods of the OpenAI API integration
7
+ module Embeddings
8
+ module_function
9
+
10
+ def embedding_url
11
+ 'embeddings'
12
+ end
13
+
14
+ def render_embedding_payload(text, model:)
15
+ {
16
+ model: model,
17
+ input: text
18
+ }
19
+ end
20
+
21
+ def parse_embedding_response(response)
22
+ data = response.body
23
+ model_id = data['model']
24
+ input_tokens = data.dig('usage', 'prompt_tokens') || 0
25
+ vectors = data['data'].map { |d| d['embedding'] }
26
+
27
+ # If we only got one embedding, return it as a single vector
28
+ vectors = vectors.size == 1 ? vectors.first : vectors
29
+
30
+ Embedding.new(
31
+ vectors: vectors,
32
+ model: model_id,
33
+ input_tokens: input_tokens
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ module OpenAI
6
+ # Models methods of the OpenAI API integration
7
+ module Models
8
+ module_function
9
+
10
+ def models_url
11
+ '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: model['created'] ? Time.at(model['created']) : nil,
19
+ display_name: capabilities.format_display_name(model['id']),
20
+ provider: slug,
21
+ type: capabilities.model_type(model['id']),
22
+ family: capabilities.model_family(model['id']),
23
+ metadata: {
24
+ object: model['object'],
25
+ owned_by: model['owned_by']
26
+ },
27
+ context_window: capabilities.context_window_for(model['id']),
28
+ max_tokens: capabilities.max_tokens_for(model['id']),
29
+ supports_vision: capabilities.supports_vision?(model['id']),
30
+ supports_functions: capabilities.supports_functions?(model['id']),
31
+ supports_json_mode: capabilities.supports_json_mode?(model['id']),
32
+ input_price_per_million: capabilities.input_price_for(model['id']),
33
+ output_price_per_million: capabilities.output_price_for(model['id'])
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ module OpenAI
6
+ # Streaming methods of the OpenAI API integration
7
+ module Streaming
8
+ module_function
9
+
10
+ def stream_url
11
+ completion_url
12
+ end
13
+
14
+ def handle_stream(&block) # rubocop:disable Metrics/MethodLength
15
+ to_json_stream do |data|
16
+ block.call(
17
+ Chunk.new(
18
+ role: :assistant,
19
+ model_id: data['model'],
20
+ content: data.dig('choices', 0, 'delta', 'content'),
21
+ tool_calls: parse_tool_calls(data.dig('choices', 0, 'delta', 'tool_calls'), parse_arguments: false),
22
+ input_tokens: data.dig('usage', 'prompt_tokens'),
23
+ output_tokens: data.dig('usage', 'completion_tokens')
24
+ )
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ module OpenAI
6
+ # Tools methods of the OpenAI API integration
7
+ module Tools
8
+ module_function
9
+
10
+ def tool_for(tool) # rubocop:disable Metrics/MethodLength
11
+ {
12
+ type: 'function',
13
+ function: {
14
+ name: tool.name,
15
+ description: tool.description,
16
+ parameters: {
17
+ type: 'object',
18
+ properties: tool.parameters.transform_values { |param| param_schema(param) },
19
+ required: tool.parameters.select { |_, p| p.required }.keys
20
+ }
21
+ }
22
+ }
23
+ end
24
+
25
+ def param_schema(param)
26
+ {
27
+ type: param.type,
28
+ description: param.description
29
+ }.compact
30
+ end
31
+
32
+ def format_tool_calls(tool_calls) # rubocop:disable Metrics/MethodLength
33
+ return nil unless tool_calls&.any?
34
+
35
+ tool_calls.map do |_, tc|
36
+ {
37
+ id: tc.id,
38
+ type: 'function',
39
+ function: {
40
+ name: tc.name,
41
+ arguments: JSON.generate(tc.arguments)
42
+ }
43
+ }
44
+ end
45
+ end
46
+
47
+ def parse_tool_calls(tool_calls, parse_arguments: true) # rubocop:disable Metrics/MethodLength
48
+ return nil unless tool_calls&.any?
49
+
50
+ tool_calls.to_h do |tc|
51
+ [
52
+ tc['id'],
53
+ ToolCall.new(
54
+ id: tc['id'],
55
+ name: tc.dig('function', 'name'),
56
+ arguments: if parse_arguments
57
+ JSON.parse(tc.dig('function',
58
+ 'arguments'))
59
+ else
60
+ tc.dig('function', 'arguments')
61
+ end
62
+ )
63
+ ]
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end