ruby_llm 1.14.1 → 1.16.0

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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -7
  3. data/lib/generators/ruby_llm/generator_helpers.rb +8 -0
  4. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +1 -1
  5. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +1 -1
  6. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +3 -3
  7. data/lib/ruby_llm/active_record/acts_as.rb +4 -26
  8. data/lib/ruby_llm/active_record/acts_as_legacy.rb +123 -29
  9. data/lib/ruby_llm/active_record/chat_methods.rb +41 -24
  10. data/lib/ruby_llm/active_record/message_methods.rb +87 -4
  11. data/lib/ruby_llm/active_record/model_methods.rb +7 -9
  12. data/lib/ruby_llm/active_record/payload_helpers.rb +3 -0
  13. data/lib/ruby_llm/active_record/tool_call_methods.rb +3 -0
  14. data/lib/ruby_llm/agent.rb +4 -2
  15. data/lib/ruby_llm/aliases.json +108 -75
  16. data/lib/ruby_llm/aliases.rb +3 -0
  17. data/lib/ruby_llm/attachment.rb +41 -40
  18. data/lib/ruby_llm/chat.rb +229 -59
  19. data/lib/ruby_llm/configuration.rb +14 -1
  20. data/lib/ruby_llm/connection.rb +36 -7
  21. data/lib/ruby_llm/content.rb +15 -1
  22. data/lib/ruby_llm/cost.rb +224 -0
  23. data/lib/ruby_llm/deprecator.rb +24 -0
  24. data/lib/ruby_llm/embedding.rb +31 -1
  25. data/lib/ruby_llm/error.rb +11 -75
  26. data/lib/ruby_llm/error_middleware.rb +81 -0
  27. data/lib/ruby_llm/image.rb +39 -4
  28. data/lib/ruby_llm/instrumentation.rb +36 -0
  29. data/lib/ruby_llm/message.rb +20 -0
  30. data/lib/ruby_llm/mime_type.rb +25 -0
  31. data/lib/ruby_llm/model/info.rb +53 -2
  32. data/lib/ruby_llm/model/pricing.rb +19 -9
  33. data/lib/ruby_llm/model/pricing_category.rb +13 -2
  34. data/lib/ruby_llm/model/pricing_tier.rb +20 -9
  35. data/lib/ruby_llm/model_registry.rb +39 -0
  36. data/lib/ruby_llm/models.json +17817 -13942
  37. data/lib/ruby_llm/models.rb +97 -31
  38. data/lib/ruby_llm/models_schema.json +3 -0
  39. data/lib/ruby_llm/provider.rb +20 -4
  40. data/lib/ruby_llm/providers/anthropic/chat.rb +49 -15
  41. data/lib/ruby_llm/providers/anthropic/models.rb +2 -0
  42. data/lib/ruby_llm/providers/anthropic/streaming.rb +2 -0
  43. data/lib/ruby_llm/providers/anthropic/tools.rb +32 -3
  44. data/lib/ruby_llm/providers/azure/media.rb +1 -1
  45. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -0
  46. data/lib/ruby_llm/providers/bedrock/chat.rb +26 -13
  47. data/lib/ruby_llm/providers/bedrock/media.rb +21 -3
  48. data/lib/ruby_llm/providers/bedrock/models.rb +1 -1
  49. data/lib/ruby_llm/providers/bedrock/streaming.rb +10 -1
  50. data/lib/ruby_llm/providers/bedrock.rb +2 -2
  51. data/lib/ruby_llm/providers/deepseek/capabilities.rb +43 -0
  52. data/lib/ruby_llm/providers/deepseek/chat.rb +9 -0
  53. data/lib/ruby_llm/providers/gemini/chat.rb +10 -4
  54. data/lib/ruby_llm/providers/gemini/images.rb +2 -2
  55. data/lib/ruby_llm/providers/gemini/media.rb +16 -9
  56. data/lib/ruby_llm/providers/gemini/streaming.rb +6 -1
  57. data/lib/ruby_llm/providers/gemini/tools.rb +5 -1
  58. data/lib/ruby_llm/providers/gpustack/chat.rb +8 -1
  59. data/lib/ruby_llm/providers/gpustack/models.rb +2 -0
  60. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -2
  61. data/lib/ruby_llm/providers/mistral/chat.rb +56 -5
  62. data/lib/ruby_llm/providers/mistral/media.rb +55 -0
  63. data/lib/ruby_llm/providers/mistral/models.rb +2 -0
  64. data/lib/ruby_llm/providers/mistral.rb +2 -2
  65. data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
  66. data/lib/ruby_llm/providers/openai/capabilities.rb +82 -12
  67. data/lib/ruby_llm/providers/openai/chat.rb +61 -7
  68. data/lib/ruby_llm/providers/openai/images.rb +58 -6
  69. data/lib/ruby_llm/providers/openai/media.rb +40 -16
  70. data/lib/ruby_llm/providers/openai/streaming.rb +7 -6
  71. data/lib/ruby_llm/providers/openai/tools.rb +2 -0
  72. data/lib/ruby_llm/providers/openai/transcription.rb +1 -0
  73. data/lib/ruby_llm/providers/openrouter/chat.rb +36 -8
  74. data/lib/ruby_llm/providers/openrouter/images.rb +2 -2
  75. data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
  76. data/lib/ruby_llm/providers/openrouter/streaming.rb +5 -6
  77. data/lib/ruby_llm/providers/perplexity/chat.rb +11 -0
  78. data/lib/ruby_llm/providers/perplexity/media.rb +62 -0
  79. data/lib/ruby_llm/providers/perplexity.rb +2 -2
  80. data/lib/ruby_llm/providers/vertexai.rb +5 -1
  81. data/lib/ruby_llm/providers/xai/chat.rb +9 -0
  82. data/lib/ruby_llm/providers/xai/models.rb +15 -27
  83. data/lib/ruby_llm/providers/xai.rb +2 -2
  84. data/lib/ruby_llm/railtie.rb +11 -1
  85. data/lib/ruby_llm/stream_accumulator.rb +45 -30
  86. data/lib/ruby_llm/streaming.rb +4 -0
  87. data/lib/ruby_llm/tokens.rb +8 -0
  88. data/lib/ruby_llm/tool.rb +24 -7
  89. data/lib/ruby_llm/tool_concurrency.rb +105 -0
  90. data/lib/ruby_llm/transcription.rb +2 -1
  91. data/lib/ruby_llm/utils.rb +39 -0
  92. data/lib/ruby_llm/version.rb +1 -1
  93. data/lib/ruby_llm.rb +11 -6
  94. data/lib/tasks/models.rake +45 -16
  95. data/lib/tasks/release.rake +50 -23
  96. metadata +35 -13
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'base64'
4
+ require 'faraday'
5
+ require 'json'
6
+
3
7
  module RubyLLM
4
8
  module Providers
5
9
  class Bedrock
@@ -158,7 +162,10 @@ module RubyLLM
158
162
  end
159
163
 
160
164
  def extract_input_tokens(metadata_usage, usage, message_usage)
161
- metadata_usage['inputTokens'] || usage['inputTokens'] || message_usage['input_tokens']
165
+ bedrock_usage = metadata_usage['inputTokens'] ? metadata_usage : usage
166
+ return Bedrock::Chat.input_tokens(bedrock_usage) if bedrock_usage['inputTokens']
167
+
168
+ message_usage['input_tokens']
162
169
  end
163
170
 
164
171
  def extract_output_tokens(metadata_usage, usage)
@@ -221,6 +228,7 @@ module RubyLLM
221
228
 
222
229
  reasoning_text = reasoning_content['reasoningText'] || {}
223
230
  return reasoning_text['text'] if reasoning_text['text']
231
+ return reasoning_content['text'] if reasoning_content['text']
224
232
  return event.dig('delta', 'thinking') if event.dig('delta', 'type') == 'thinking_delta'
225
233
 
226
234
  nil
@@ -241,6 +249,7 @@ module RubyLLM
241
249
  reasoning_content = delta['reasoningContent'] || {}
242
250
  reasoning_text = reasoning_content['reasoningText'] || {}
243
251
  return reasoning_text['signature'] if reasoning_text['signature']
252
+ return reasoning_content['signature'] if reasoning_content['signature']
244
253
  return event.dig('delta', 'signature') if event.dig('delta', 'type') == 'signature_delta'
245
254
 
246
255
  nil
@@ -11,7 +11,7 @@ module RubyLLM
11
11
  include Bedrock::Streaming
12
12
 
13
13
  def api_base
14
- "https://bedrock-runtime.#{bedrock_region}.amazonaws.com"
14
+ @config.bedrock_api_base || "https://bedrock-runtime.#{bedrock_region}.amazonaws.com"
15
15
  end
16
16
 
17
17
  def headers
@@ -54,7 +54,7 @@ module RubyLLM
54
54
 
55
55
  class << self
56
56
  def configuration_options
57
- %i[bedrock_api_key bedrock_secret_key bedrock_region bedrock_session_token]
57
+ %i[bedrock_api_key bedrock_secret_key bedrock_region bedrock_session_token bedrock_api_base]
58
58
  end
59
59
 
60
60
  def configuration_requirements
@@ -7,6 +7,19 @@ module RubyLLM
7
7
  module Capabilities
8
8
  module_function
9
9
 
10
+ DEFAULT_CONTEXT_WINDOW = 1_000_000
11
+ DEFAULT_MAX_OUTPUT_TOKENS = 384_000
12
+ DEFAULT_PRICES = {
13
+ input: 0.14,
14
+ output: 0.28,
15
+ cache_read: 0.0028
16
+ }.freeze
17
+ PRO_PRICES = {
18
+ input: 0.435,
19
+ output: 0.87,
20
+ cache_read: 0.003625
21
+ }.freeze
22
+
10
23
  def supports_tool_choice?(_model_id)
11
24
  true
12
25
  end
@@ -14,6 +27,36 @@ module RubyLLM
14
27
  def supports_tool_parallel_control?(_model_id)
15
28
  false
16
29
  end
30
+
31
+ def context_window_for(_model_id)
32
+ DEFAULT_CONTEXT_WINDOW
33
+ end
34
+
35
+ def max_tokens_for(_model_id)
36
+ DEFAULT_MAX_OUTPUT_TOKENS
37
+ end
38
+
39
+ def critical_capabilities_for(model_id)
40
+ v4_model = model_id.start_with?('deepseek-v4-')
41
+ capabilities = ['function_calling']
42
+ capabilities << 'structured_output' if v4_model
43
+ capabilities << 'reasoning' if model_id == 'deepseek-reasoner' || v4_model
44
+ capabilities
45
+ end
46
+
47
+ def pricing_for(model_id)
48
+ prices = model_id == 'deepseek-v4-pro' ? PRO_PRICES : DEFAULT_PRICES
49
+
50
+ {
51
+ text_tokens: {
52
+ standard: {
53
+ input_per_million: prices[:input],
54
+ output_per_million: prices[:output],
55
+ cache_read_input_per_million: prices[:cache_read]
56
+ }
57
+ }
58
+ }
59
+ end
17
60
  end
18
61
  end
19
62
  end
@@ -10,6 +10,15 @@ module RubyLLM
10
10
  def format_role(role)
11
11
  role.to_s
12
12
  end
13
+
14
+ def format_content(content)
15
+ OpenAI::Media.format_content(
16
+ content,
17
+ document_attachments: :none,
18
+ image_attachments: false,
19
+ audio_attachments: false
20
+ )
21
+ end
13
22
  end
14
23
  end
15
24
  end
@@ -72,8 +72,7 @@ module RubyLLM
72
72
  def format_role(role)
73
73
  case role
74
74
  when :assistant then 'model'
75
- when :system then 'user'
76
- when :tool then 'function'
75
+ when :system, :tool then 'user'
77
76
  else role.to_s
78
77
  end
79
78
  end
@@ -118,7 +117,7 @@ module RubyLLM
118
117
  signature: extract_thought_signature(parts)
119
118
  ),
120
119
  tool_calls: tool_calls,
121
- input_tokens: data.dig('usageMetadata', 'promptTokenCount'),
120
+ input_tokens: input_tokens(data),
122
121
  output_tokens: calculate_output_tokens(data),
123
122
  cached_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'),
124
123
  thinking_tokens: data.dig('usageMetadata', 'thoughtsTokenCount'),
@@ -127,6 +126,13 @@ module RubyLLM
127
126
  )
128
127
  end
129
128
 
129
+ def input_tokens(data)
130
+ prompt_tokens = data.dig('usageMetadata', 'promptTokenCount')
131
+ return unless prompt_tokens
132
+
133
+ [prompt_tokens.to_i - data.dig('usageMetadata', 'cachedContentTokenCount').to_i, 0].max
134
+ end
135
+
130
136
  def convert_schema_to_gemini(schema)
131
137
  return nil unless schema
132
138
 
@@ -307,7 +313,7 @@ module RubyLLM
307
313
  end
308
314
 
309
315
  def build_tool_response(parts)
310
- { role: 'function', parts: parts }
316
+ { role: 'user', parts: parts }
311
317
  end
312
318
 
313
319
  def remember_tool_calls
@@ -5,11 +5,11 @@ module RubyLLM
5
5
  class Gemini
6
6
  # Image generation methods for the Gemini API implementation
7
7
  module Images
8
- def images_url
8
+ def images_url(with: nil, mask: nil) # rubocop:disable Lint/UnusedMethodArgument
9
9
  "models/#{@model}:predict"
10
10
  end
11
11
 
12
- def render_image_payload(prompt, model:, size:)
12
+ def render_image_payload(prompt, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Lint/UnusedMethodArgument,Metrics/ParameterLists
13
13
  RubyLLM.logger.debug { "Ignoring size #{size}. Gemini does not support image size customization." }
14
14
  @model = model
15
15
  {
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'base64'
4
+ require 'stringio'
5
+
3
6
  module RubyLLM
4
7
  module Providers
5
8
  class Gemini # rubocop:disable Style/Documentation
@@ -16,19 +19,23 @@ module RubyLLM
16
19
  parts << format_text(content.text) if content.text
17
20
 
18
21
  content.attachments.each do |attachment|
19
- case attachment.type
20
- when :text
21
- parts << format_text_file(attachment)
22
- when :unknown
23
- raise UnsupportedAttachmentError, attachment.mime_type
24
- else
25
- parts << format_attachment(attachment)
26
- end
22
+ parts << format_content_attachment(attachment)
27
23
  end
28
24
 
29
25
  parts
30
26
  end
31
27
 
28
+ def format_content_attachment(attachment)
29
+ case attachment.type
30
+ when :text
31
+ format_text_file(attachment)
32
+ when :document, :unknown
33
+ raise UnsupportedAttachmentError, attachment.mime_type
34
+ else
35
+ format_attachment(attachment)
36
+ end
37
+ end
38
+
32
39
  def format_attachment(attachment)
33
40
  {
34
41
  inline_data: {
@@ -71,7 +78,7 @@ module RubyLLM
71
78
  text = nil if text.empty?
72
79
  return text if attachments.empty?
73
80
 
74
- Content.new(text:, attachments:)
81
+ Content.new(text, attachments)
75
82
  end
76
83
 
77
84
  def build_inline_attachment(inline_data, index)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module RubyLLM
4
6
  module Providers
5
7
  class Gemini
@@ -70,7 +72,10 @@ module RubyLLM
70
72
  end
71
73
 
72
74
  def extract_input_tokens(data)
73
- data.dig('usageMetadata', 'promptTokenCount')
75
+ prompt_tokens = data.dig('usageMetadata', 'promptTokenCount')
76
+ return unless prompt_tokens
77
+
78
+ [prompt_tokens.to_i - data.dig('usageMetadata', 'cachedContentTokenCount').to_i, 0].max
74
79
  end
75
80
 
76
81
  def extract_output_tokens(data)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module RubyLLM
4
6
  module Providers
5
7
  class Gemini
@@ -46,13 +48,15 @@ module RubyLLM
46
48
 
47
49
  def format_tool_result(msg, function_name = nil)
48
50
  function_name ||= msg.tool_call_id
51
+ content = msg.content
52
+ content = '(no output)' if content.nil? || (content.respond_to?(:empty?) && content.empty?)
49
53
 
50
54
  [{
51
55
  functionResponse: {
52
56
  name: function_name,
53
57
  response: {
54
58
  name: function_name,
55
- content: Media.format_content(msg.content)
59
+ content: Media.format_content(content)
56
60
  }
57
61
  }
58
62
  }]
@@ -11,13 +11,20 @@ module RubyLLM
11
11
  messages.map do |msg|
12
12
  {
13
13
  role: format_role(msg.role),
14
- content: GPUStack::Media.format_content(msg.content),
14
+ content: format_message_content(msg),
15
15
  tool_calls: format_tool_calls(msg.tool_calls),
16
16
  tool_call_id: msg.tool_call_id
17
17
  }.compact.merge(OpenAI::Chat.format_thinking(msg))
18
18
  end
19
19
  end
20
20
 
21
+ def format_message_content(msg)
22
+ content = GPUStack::Media.format_content(msg.content)
23
+ return '' if content.nil? && OpenAI::Chat.thinking_only_assistant_message?(msg)
24
+
25
+ content
26
+ end
27
+
21
28
  def format_role(role)
22
29
  role.to_s
23
30
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module RubyLLM
4
6
  module Providers
5
7
  class GPUStack
@@ -31,6 +31,11 @@ module RubyLLM
31
31
  !model_id.match?(/embed|moderation|ocr|voxtral|transcriptions/) && supports_tools?(model_id)
32
32
  end
33
33
 
34
+ def supports_reasoning?(model_id)
35
+ model_id.match?(/magistral/) ||
36
+ model_id.match?(/\Amistral-(?:small-latest|medium-(?:3(?:[.-]5)?|latest))\z/)
37
+ end
38
+
34
39
  def format_display_name(model_id)
35
40
  case model_id
36
41
  when /mistral-large/ then 'Mistral Large'
@@ -101,7 +106,7 @@ module RubyLLM
101
106
  capabilities << 'structured_output' if supports_json_mode?(model_id)
102
107
  capabilities << 'vision' if supports_vision?(model_id)
103
108
 
104
- capabilities << 'reasoning' if model_id.match?(/magistral/)
109
+ capabilities << 'reasoning' if supports_reasoning?(model_id)
105
110
  capabilities << 'batch' unless model_id.match?(/voxtral|ocr|embed|moderation/)
106
111
  capabilities << 'fine_tuning' if model_id.match?(/mistral-(small|medium|large)|devstral/)
107
112
  capabilities << 'distillation' if model_id.match?(/ministral/)
@@ -118,7 +123,7 @@ module RubyLLM
118
123
  }
119
124
  end
120
125
 
121
- def release_date_for(model_id)
126
+ def release_date_for(model_id) # rubocop:disable Metrics/CyclomaticComplexity
122
127
  case model_id
123
128
  when 'open-mistral-7b', 'mistral-tiny' then '2023-09-27'
124
129
  when 'mistral-medium-2312', 'mistral-small-2312', 'mistral-small',
@@ -27,14 +27,32 @@ module RubyLLM
27
27
  schema: nil, thinking: nil, tool_prefs: nil)
28
28
  payload = super
29
29
  payload.delete(:stream_options)
30
- payload.delete(:reasoning_effort)
31
- warn_on_unsupported_thinking(model, thinking)
30
+ configure_thinking_payload(payload, model, thinking)
31
+ normalize_required_tool_choice(payload)
32
32
  payload
33
33
  end
34
34
  # rubocop:enable Metrics/ParameterLists
35
35
 
36
+ def build_tool_choice(tool_choice)
37
+ return 'any' if tool_choice == :required
38
+
39
+ OpenAI::Tools.build_tool_choice(tool_choice)
40
+ end
41
+
42
+ def normalize_required_tool_choice(payload)
43
+ return unless payload[:tool_choice] == 'any' && Array(payload[:tools]).one?
44
+
45
+ function_name = payload.dig(:tools, 0, :function, :name)
46
+ return unless function_name
47
+
48
+ payload[:tool_choice] = {
49
+ type: 'function',
50
+ function: { name: function_name }
51
+ }
52
+ end
53
+
36
54
  def format_content_with_thinking(msg)
37
- formatted_content = OpenAI::Media.format_content(msg.content)
55
+ formatted_content = Mistral::Media.format_content(msg.content)
38
56
  return formatted_content unless msg.role == :assistant && msg.thinking
39
57
 
40
58
  content_blocks = build_thinking_blocks(msg.thinking)
@@ -45,14 +63,47 @@ module RubyLLM
45
63
 
46
64
  def warn_on_unsupported_thinking(model, thinking)
47
65
  return unless thinking&.enabled?
48
- return if model.id.to_s.include?('magistral')
66
+ return if native_reasoning_model?(model.id) || adjustable_reasoning_model?(model.id)
49
67
 
50
68
  RubyLLM.logger.warn(
51
- 'Mistral thinking is only supported on Magistral models. ' \
69
+ 'Mistral thinking is only supported on Magistral and adjustable-reasoning models. ' \
52
70
  "Ignoring thinking settings for #{model.id}."
53
71
  )
54
72
  end
55
73
 
74
+ def configure_thinking_payload(payload, model, thinking)
75
+ return unless thinking&.enabled?
76
+
77
+ if native_reasoning_model?(model.id)
78
+ configure_native_reasoning_payload(payload, thinking)
79
+ elsif adjustable_reasoning_model?(model.id)
80
+ payload[:reasoning_effort] = reasoning_effort_for(thinking)
81
+ else
82
+ payload.delete(:reasoning_effort)
83
+ warn_on_unsupported_thinking(model, thinking)
84
+ end
85
+ end
86
+
87
+ def configure_native_reasoning_payload(payload, thinking)
88
+ payload.delete(:reasoning_effort)
89
+ payload[:prompt_mode] = thinking.effort == 'none' ? nil : 'reasoning'
90
+ end
91
+
92
+ def reasoning_effort_for(thinking)
93
+ effort = thinking.respond_to?(:effort) ? thinking.effort : nil
94
+ return effort if %w[high none].include?(effort)
95
+
96
+ 'high'
97
+ end
98
+
99
+ def native_reasoning_model?(model_id)
100
+ model_id.to_s.include?('magistral')
101
+ end
102
+
103
+ def adjustable_reasoning_model?(model_id)
104
+ model_id.to_s.match?(/\Amistral-(?:small-latest|medium-(?:3(?:[.-]5)?|latest))\z/)
105
+ end
106
+
56
107
  def build_thinking_blocks(thinking)
57
108
  return [] unless thinking
58
109
 
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Mistral
6
+ # Handles media content for Mistral Chat Completions.
7
+ module Media
8
+ module_function
9
+
10
+ def format_content(content) # rubocop:disable Metrics/PerceivedComplexity
11
+ if content.is_a?(RubyLLM::Content::Raw)
12
+ value = content.value
13
+ return value.is_a?(Hash) ? value.to_json : value
14
+ end
15
+ return content.to_json if content.is_a?(Hash) || content.is_a?(Array)
16
+ return content unless content.is_a?(Content)
17
+
18
+ parts = []
19
+ parts << OpenAI::Media.format_text(content.text) if content.text
20
+
21
+ content.attachments.each do |attachment|
22
+ case attachment.type
23
+ when :image
24
+ parts << format_image(attachment)
25
+ when :audio
26
+ parts << OpenAI::Media.format_audio(attachment)
27
+ when :pdf, :document
28
+ parts << format_document(attachment)
29
+ when :text
30
+ parts << OpenAI::Media.format_text_file(attachment)
31
+ else
32
+ raise UnsupportedAttachmentError, attachment.mime_type
33
+ end
34
+ end
35
+
36
+ parts
37
+ end
38
+
39
+ def format_image(image)
40
+ {
41
+ type: 'image_url',
42
+ image_url: image.url? ? image.source.to_s : image.for_llm
43
+ }
44
+ end
45
+
46
+ def format_document(document)
47
+ {
48
+ type: 'document_url',
49
+ document_url: document.url? ? document.source.to_s : document.for_llm
50
+ }
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module RubyLLM
4
6
  module Providers
5
7
  class Mistral
@@ -9,7 +9,7 @@ module RubyLLM
9
9
  include Mistral::Embeddings
10
10
 
11
11
  def api_base
12
- 'https://api.mistral.ai/v1'
12
+ @config.mistral_api_base || 'https://api.mistral.ai/v1'
13
13
  end
14
14
 
15
15
  def headers
@@ -24,7 +24,7 @@ module RubyLLM
24
24
  end
25
25
 
26
26
  def configuration_options
27
- %i[mistral_api_key]
27
+ %i[mistral_api_key mistral_api_base]
28
28
  end
29
29
 
30
30
  def configuration_requirements
@@ -11,13 +11,20 @@ module RubyLLM
11
11
  messages.map do |msg|
12
12
  {
13
13
  role: format_role(msg.role),
14
- content: Ollama::Media.format_content(msg.content),
14
+ content: format_message_content(msg),
15
15
  tool_calls: format_tool_calls(msg.tool_calls),
16
16
  tool_call_id: msg.tool_call_id
17
17
  }.compact.merge(OpenAI::Chat.format_thinking(msg))
18
18
  end
19
19
  end
20
20
 
21
+ def format_message_content(msg)
22
+ content = Ollama::Media.format_content(msg.content)
23
+ return '' if content.nil? && OpenAI::Chat.thinking_only_assistant_message?(msg)
24
+
25
+ content
26
+ end
27
+
21
28
  def format_role(role)
22
29
  role.to_s
23
30
  end