ruby_llm 1.13.2 → 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 (171) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -13
  3. data/lib/generators/ruby_llm/agent/agent_generator.rb +36 -0
  4. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -0
  5. data/lib/generators/ruby_llm/agent/templates/instructions.txt.erb.tt +0 -0
  6. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +110 -41
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +14 -15
  8. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -11
  9. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +2 -2
  10. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +1 -1
  12. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
  13. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
  15. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
  18. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
  30. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +2 -2
  31. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +2 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +19 -7
  33. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +1 -1
  34. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +5 -3
  35. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -1
  37. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +1 -1
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -7
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +5 -7
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +11 -12
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +27 -17
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -4
  49. data/lib/generators/ruby_llm/generator_helpers.rb +41 -17
  50. data/lib/generators/ruby_llm/install/install_generator.rb +21 -18
  51. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +3 -4
  52. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +1 -1
  53. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +3 -3
  54. data/lib/generators/ruby_llm/schema/schema_generator.rb +26 -0
  55. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +2 -0
  56. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +9 -0
  57. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +13 -0
  58. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +13 -0
  59. data/lib/generators/ruby_llm/tool/tool_generator.rb +96 -0
  60. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +1 -1
  61. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  62. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  63. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +5 -7
  64. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  65. data/lib/ruby_llm/active_record/acts_as.rb +6 -26
  66. data/lib/ruby_llm/active_record/acts_as_legacy.rb +125 -29
  67. data/lib/ruby_llm/active_record/chat_methods.rb +50 -26
  68. data/lib/ruby_llm/active_record/message_methods.rb +104 -4
  69. data/lib/ruby_llm/active_record/model_methods.rb +8 -10
  70. data/lib/ruby_llm/active_record/payload_helpers.rb +29 -0
  71. data/lib/ruby_llm/active_record/tool_call_methods.rb +18 -0
  72. data/lib/ruby_llm/agent.rb +15 -2
  73. data/lib/ruby_llm/aliases.json +141 -89
  74. data/lib/ruby_llm/aliases.rb +3 -0
  75. data/lib/ruby_llm/attachment.rb +44 -40
  76. data/lib/ruby_llm/chat.rb +229 -59
  77. data/lib/ruby_llm/configuration.rb +69 -75
  78. data/lib/ruby_llm/connection.rb +37 -10
  79. data/lib/ruby_llm/content.rb +15 -1
  80. data/lib/ruby_llm/cost.rb +224 -0
  81. data/lib/ruby_llm/deprecator.rb +24 -0
  82. data/lib/ruby_llm/embedding.rb +31 -1
  83. data/lib/ruby_llm/error.rb +16 -75
  84. data/lib/ruby_llm/error_middleware.rb +81 -0
  85. data/lib/ruby_llm/image.rb +39 -4
  86. data/lib/ruby_llm/instrumentation.rb +36 -0
  87. data/lib/ruby_llm/message.rb +20 -0
  88. data/lib/ruby_llm/mime_type.rb +25 -0
  89. data/lib/ruby_llm/model/info.rb +67 -14
  90. data/lib/ruby_llm/model/pricing.rb +19 -9
  91. data/lib/ruby_llm/model/pricing_category.rb +13 -2
  92. data/lib/ruby_llm/model/pricing_tier.rb +20 -9
  93. data/lib/ruby_llm/model_registry.rb +39 -0
  94. data/lib/ruby_llm/models.json +21796 -20601
  95. data/lib/ruby_llm/models.rb +107 -34
  96. data/lib/ruby_llm/models_schema.json +3 -0
  97. data/lib/ruby_llm/provider.rb +25 -4
  98. data/lib/ruby_llm/providers/anthropic/capabilities.rb +1 -133
  99. data/lib/ruby_llm/providers/anthropic/chat.rb +49 -15
  100. data/lib/ruby_llm/providers/anthropic/models.rb +4 -8
  101. data/lib/ruby_llm/providers/anthropic/streaming.rb +2 -0
  102. data/lib/ruby_llm/providers/anthropic/tools.rb +32 -3
  103. data/lib/ruby_llm/providers/anthropic.rb +4 -0
  104. data/lib/ruby_llm/providers/azure/media.rb +1 -1
  105. data/lib/ruby_llm/providers/azure.rb +4 -0
  106. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -0
  107. data/lib/ruby_llm/providers/bedrock/chat.rb +26 -13
  108. data/lib/ruby_llm/providers/bedrock/media.rb +21 -3
  109. data/lib/ruby_llm/providers/bedrock/models.rb +1 -1
  110. data/lib/ruby_llm/providers/bedrock/streaming.rb +10 -1
  111. data/lib/ruby_llm/providers/bedrock.rb +5 -1
  112. data/lib/ruby_llm/providers/deepseek/capabilities.rb +28 -103
  113. data/lib/ruby_llm/providers/deepseek/chat.rb +9 -0
  114. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  115. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -215
  116. data/lib/ruby_llm/providers/gemini/chat.rb +10 -4
  117. data/lib/ruby_llm/providers/gemini/images.rb +2 -2
  118. data/lib/ruby_llm/providers/gemini/media.rb +16 -9
  119. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  120. data/lib/ruby_llm/providers/gemini/streaming.rb +6 -1
  121. data/lib/ruby_llm/providers/gemini/tools.rb +5 -1
  122. data/lib/ruby_llm/providers/gemini.rb +4 -0
  123. data/lib/ruby_llm/providers/gpustack/chat.rb +8 -1
  124. data/lib/ruby_llm/providers/gpustack/models.rb +2 -0
  125. data/lib/ruby_llm/providers/gpustack.rb +4 -0
  126. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -2
  127. data/lib/ruby_llm/providers/mistral/chat.rb +56 -5
  128. data/lib/ruby_llm/providers/mistral/media.rb +55 -0
  129. data/lib/ruby_llm/providers/mistral/models.rb +2 -0
  130. data/lib/ruby_llm/providers/mistral.rb +5 -1
  131. data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
  132. data/lib/ruby_llm/providers/ollama.rb +4 -0
  133. data/lib/ruby_llm/providers/openai/capabilities.rb +157 -195
  134. data/lib/ruby_llm/providers/openai/chat.rb +61 -7
  135. data/lib/ruby_llm/providers/openai/images.rb +58 -6
  136. data/lib/ruby_llm/providers/openai/media.rb +40 -16
  137. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  138. data/lib/ruby_llm/providers/openai/streaming.rb +7 -6
  139. data/lib/ruby_llm/providers/openai/tools.rb +2 -0
  140. data/lib/ruby_llm/providers/openai/transcription.rb +1 -0
  141. data/lib/ruby_llm/providers/openai.rb +10 -0
  142. data/lib/ruby_llm/providers/openrouter/chat.rb +36 -8
  143. data/lib/ruby_llm/providers/openrouter/images.rb +3 -3
  144. data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
  145. data/lib/ruby_llm/providers/openrouter/streaming.rb +5 -6
  146. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  147. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  148. data/lib/ruby_llm/providers/perplexity/chat.rb +11 -0
  149. data/lib/ruby_llm/providers/perplexity/media.rb +62 -0
  150. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  151. data/lib/ruby_llm/providers/perplexity.rb +5 -1
  152. data/lib/ruby_llm/providers/vertexai.rb +8 -0
  153. data/lib/ruby_llm/providers/xai/chat.rb +9 -0
  154. data/lib/ruby_llm/providers/xai/models.rb +15 -27
  155. data/lib/ruby_llm/providers/xai.rb +5 -1
  156. data/lib/ruby_llm/railtie.rb +11 -1
  157. data/lib/ruby_llm/stream_accumulator.rb +45 -30
  158. data/lib/ruby_llm/streaming.rb +4 -0
  159. data/lib/ruby_llm/tokens.rb +8 -0
  160. data/lib/ruby_llm/tool.rb +24 -7
  161. data/lib/ruby_llm/tool_concurrency.rb +105 -0
  162. data/lib/ruby_llm/transcription.rb +2 -1
  163. data/lib/ruby_llm/utils.rb +39 -0
  164. data/lib/ruby_llm/version.rb +1 -1
  165. data/lib/ruby_llm.rb +11 -6
  166. data/lib/tasks/models.rake +45 -16
  167. data/lib/tasks/release.rake +51 -24
  168. data/lib/tasks/ruby_llm.rake +6 -5
  169. data/lib/tasks/vcr.rake +1 -1
  170. metadata +81 -21
  171. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'faraday'
4
+ require 'stringio'
5
+
3
6
  module RubyLLM
4
7
  module Providers
5
8
  class OpenAI
@@ -7,30 +10,79 @@ module RubyLLM
7
10
  module Images
8
11
  module_function
9
12
 
10
- def images_url
11
- 'images/generations'
13
+ def images_url(with: nil, mask: nil)
14
+ editing?(with, mask) ? 'images/edits' : 'images/generations'
12
15
  end
13
16
 
14
- def render_image_payload(prompt, model:, size:)
17
+ def render_image_payload(prompt, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Metrics/ParameterLists
18
+ return render_edit_payload(prompt, model:, with:, mask:, params:) if editing?(with, mask)
19
+
15
20
  {
16
21
  model: model,
17
22
  prompt: prompt,
18
23
  n: 1,
19
24
  size: size
20
- }
25
+ }.merge(params)
21
26
  end
22
27
 
23
28
  def parse_image_response(response, model:)
24
29
  data = response.body
25
- image_data = data['data'].first
30
+ image_data = Array(data['data']).first
31
+
32
+ raise Error.new(nil, 'Unexpected response format from OpenAI image API') unless image_data
26
33
 
27
34
  Image.new(
28
35
  url: image_data['url'],
29
36
  mime_type: 'image/png', # DALL-E typically returns PNGs
30
37
  revised_prompt: image_data['revised_prompt'],
31
38
  model_id: model,
32
- data: image_data['b64_json']
39
+ data: image_data['b64_json'],
40
+ usage: data['usage'] || {}
41
+ )
42
+ end
43
+
44
+ def validate_paint_inputs!(with:, mask:)
45
+ return unless editing?(with, mask)
46
+
47
+ raise ArgumentError, 'with: is required when mask: is provided' if mask && !attachments?(with)
48
+ end
49
+
50
+ def render_edit_payload(prompt, model:, with:, mask:, params:)
51
+ payload = params.merge(
52
+ model: model,
53
+ prompt: prompt,
54
+ image: build_upload_parts(with),
55
+ n: 1
33
56
  )
57
+ payload[:mask] = build_upload_part(mask) if mask
58
+ payload
59
+ end
60
+
61
+ def build_upload_parts(sources)
62
+ Array(sources).filter_map do |source|
63
+ next if blank_attachment?(source)
64
+
65
+ build_upload_part(source)
66
+ end
67
+ end
68
+
69
+ def build_upload_part(source)
70
+ attachment = Attachment.new(source)
71
+ raise UnsupportedAttachmentError, attachment.mime_type unless attachment.image?
72
+
73
+ Faraday::UploadIO.new(StringIO.new(attachment.content), attachment.mime_type, attachment.filename)
74
+ end
75
+
76
+ def editing?(with, mask)
77
+ attachments?(with) || !mask.nil?
78
+ end
79
+
80
+ def attachments?(value)
81
+ Array(value).any? { |item| !blank_attachment?(item) }
82
+ end
83
+
84
+ def blank_attachment?(value)
85
+ value.nil? || (value.is_a?(String) && value.strip.empty?)
34
86
  end
35
87
  end
36
88
  end
@@ -7,7 +7,7 @@ module RubyLLM
7
7
  module Media
8
8
  module_function
9
9
 
10
- def format_content(content) # rubocop:disable Metrics/PerceivedComplexity
10
+ def format_content(content, document_attachments: :pdf, image_attachments: true, audio_attachments: true)
11
11
  if content.is_a?(RubyLLM::Content::Raw)
12
12
  value = content.value
13
13
  return value.is_a?(Hash) ? value.to_json : value
@@ -19,23 +19,36 @@ module RubyLLM
19
19
  parts << format_text(content.text) if content.text
20
20
 
21
21
  content.attachments.each do |attachment|
22
- case attachment.type
23
- when :image
24
- parts << format_image(attachment)
25
- when :pdf
26
- parts << format_pdf(attachment)
27
- when :audio
28
- parts << format_audio(attachment)
29
- when :text
30
- parts << format_text_file(attachment)
31
- else
32
- raise UnsupportedAttachmentError, attachment.type
33
- end
22
+ parts << format_attachment(
23
+ attachment,
24
+ document_attachments:,
25
+ image_attachments:,
26
+ audio_attachments:
27
+ )
34
28
  end
35
29
 
36
30
  parts
37
31
  end
38
32
 
33
+ def format_attachment(attachment, document_attachments:, image_attachments:, audio_attachments:)
34
+ case attachment.type
35
+ when :image
36
+ raise UnsupportedAttachmentError, attachment.mime_type unless image_attachments
37
+
38
+ format_image(attachment)
39
+ when :audio
40
+ raise UnsupportedAttachmentError, attachment.mime_type unless audio_attachments
41
+
42
+ format_audio(attachment)
43
+ when :pdf, :document
44
+ format_document_attachment(attachment, document_attachments)
45
+ when :text
46
+ format_text_file(attachment)
47
+ else
48
+ raise UnsupportedAttachmentError, attachment.mime_type
49
+ end
50
+ end
51
+
39
52
  def format_image(image)
40
53
  {
41
54
  type: 'image_url',
@@ -45,16 +58,20 @@ module RubyLLM
45
58
  }
46
59
  end
47
60
 
48
- def format_pdf(pdf)
61
+ def format_document(document)
49
62
  {
50
63
  type: 'file',
51
64
  file: {
52
- filename: pdf.filename,
53
- file_data: pdf.for_llm
65
+ filename: document.filename,
66
+ file_data: document.for_llm
54
67
  }
55
68
  }
56
69
  end
57
70
 
71
+ def format_pdf(pdf)
72
+ format_document(pdf)
73
+ end
74
+
58
75
  def format_text_file(text_file)
59
76
  {
60
77
  type: 'text',
@@ -78,6 +95,13 @@ module RubyLLM
78
95
  text: text
79
96
  }
80
97
  end
98
+
99
+ def format_document_attachment(attachment, strategy)
100
+ return format_document(attachment) if strategy == :all
101
+ return format_document(attachment) if strategy == :pdf && attachment.pdf?
102
+
103
+ raise UnsupportedAttachmentError, attachment.mime_type
104
+ end
81
105
  end
82
106
  end
83
107
  end
@@ -17,14 +17,12 @@ module RubyLLM
17
17
 
18
18
  Model::Info.new(
19
19
  id: model_id,
20
- name: capabilities.format_display_name(model_id),
20
+ name: model_id,
21
21
  provider: slug,
22
- family: capabilities.model_family(model_id),
23
22
  created_at: model_data['created'] ? Time.at(model_data['created']) : nil,
24
23
  context_window: capabilities.context_window_for(model_id),
25
24
  max_output_tokens: capabilities.max_tokens_for(model_id),
26
- modalities: capabilities.modalities_for(model_id),
27
- capabilities: capabilities.capabilities_for(model_id),
25
+ capabilities: capabilities.critical_capabilities_for(model_id),
28
26
  pricing: capabilities.pricing_for(model_id),
29
27
  metadata: {
30
28
  object: model_data['object'],
@@ -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 OpenAI
@@ -13,7 +15,6 @@ module RubyLLM
13
15
 
14
16
  def build_chunk(data)
15
17
  usage = data['usage'] || {}
16
- cached_tokens = usage.dig('prompt_tokens_details', 'cached_tokens')
17
18
  delta = data.dig('choices', 0, 'delta') || {}
18
19
  content_source = delta['content'] || data.dig('choices', 0, 'message', 'content')
19
20
  content, thinking_from_blocks = OpenAI::Chat.extract_content_and_thinking(content_source)
@@ -27,11 +28,11 @@ module RubyLLM
27
28
  signature: delta['reasoning_signature']
28
29
  ),
29
30
  tool_calls: parse_tool_calls(delta['tool_calls'], parse_arguments: false),
30
- input_tokens: usage['prompt_tokens'],
31
- output_tokens: usage['completion_tokens'],
32
- cached_tokens: cached_tokens,
33
- cache_creation_tokens: 0,
34
- thinking_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens')
31
+ input_tokens: OpenAI::Chat.input_tokens(usage),
32
+ output_tokens: OpenAI::Chat.output_tokens(usage),
33
+ cached_tokens: OpenAI::Chat.cache_read_tokens(usage),
34
+ cache_creation_tokens: OpenAI::Chat.cache_write_tokens(usage),
35
+ thinking_tokens: OpenAI::Chat.thinking_tokens(usage)
35
36
  )
36
37
  end
37
38
 
@@ -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 OpenAI
@@ -60,6 +60,7 @@ module RubyLLM
60
60
  language: data['language'],
61
61
  duration: data['duration'],
62
62
  segments: data['segments'],
63
+ words: data['words'],
63
64
  input_tokens: usage['input_tokens'] || usage['prompt_tokens'],
64
65
  output_tokens: usage['output_tokens'] || usage['completion_tokens']
65
66
  )
@@ -35,6 +35,16 @@ module RubyLLM
35
35
  OpenAI::Capabilities
36
36
  end
37
37
 
38
+ def configuration_options
39
+ %i[
40
+ openai_api_key
41
+ openai_api_base
42
+ openai_organization_id
43
+ openai_project_id
44
+ openai_use_system_role
45
+ ]
46
+ end
47
+
38
48
  def configuration_requirements
39
49
  %i[openai_api_key]
40
50
  end
@@ -52,7 +52,7 @@ module RubyLLM
52
52
 
53
53
  def parse_completion_response(response)
54
54
  data = response.body
55
- return if data.empty?
55
+ return if data.nil? || data.empty?
56
56
 
57
57
  raise Error.new(response, data.dig('error', 'message')) if data.dig('error', 'message')
58
58
 
@@ -60,8 +60,7 @@ module RubyLLM
60
60
  return unless message_data
61
61
 
62
62
  usage = data['usage'] || {}
63
- cached_tokens = usage.dig('prompt_tokens_details', 'cached_tokens')
64
- thinking_tokens = usage.dig('completion_tokens_details', 'reasoning_tokens')
63
+ thinking_tokens = thinking_tokens(usage)
65
64
  thinking_text = extract_thinking_text(message_data)
66
65
  thinking_signature = extract_thinking_signature(message_data)
67
66
 
@@ -70,27 +69,56 @@ module RubyLLM
70
69
  content: message_data['content'],
71
70
  thinking: Thinking.build(text: thinking_text, signature: thinking_signature),
72
71
  tool_calls: OpenAI::Tools.parse_tool_calls(message_data['tool_calls']),
73
- input_tokens: usage['prompt_tokens'],
74
- output_tokens: usage['completion_tokens'],
75
- cached_tokens: cached_tokens,
76
- cache_creation_tokens: 0,
72
+ input_tokens: input_tokens(usage),
73
+ output_tokens: output_tokens(usage),
74
+ cached_tokens: cache_read_tokens(usage),
75
+ cache_creation_tokens: cache_write_tokens(usage),
77
76
  thinking_tokens: thinking_tokens,
78
77
  model_id: data['model'],
79
78
  raw: response
80
79
  )
81
80
  end
82
81
 
82
+ def input_tokens(usage)
83
+ return usage['prompt_cache_miss_tokens'] if usage['prompt_cache_miss_tokens']
84
+
85
+ prompt_tokens = usage['prompt_tokens']
86
+ return unless prompt_tokens
87
+
88
+ [prompt_tokens.to_i - cache_read_tokens(usage).to_i - cache_write_tokens(usage).to_i, 0].max
89
+ end
90
+
91
+ def output_tokens(usage)
92
+ OpenAI::Chat.output_tokens(usage)
93
+ end
94
+
95
+ def cache_read_tokens(usage)
96
+ usage.dig('prompt_tokens_details', 'cached_tokens') || usage['prompt_cache_hit_tokens']
97
+ end
98
+
99
+ def cache_write_tokens(usage)
100
+ usage.dig('prompt_tokens_details', 'cache_write_tokens') || 0
101
+ end
102
+
103
+ def thinking_tokens(usage)
104
+ OpenAI::Chat.thinking_tokens(usage)
105
+ end
106
+
83
107
  def format_messages(messages)
84
108
  messages.map do |msg|
85
109
  {
86
110
  role: format_role(msg.role),
87
- content: OpenAI::Media.format_content(msg.content),
111
+ content: format_content(msg.content),
88
112
  tool_calls: OpenAI::Tools.format_tool_calls(msg.tool_calls),
89
113
  tool_call_id: msg.tool_call_id
90
114
  }.compact.merge(format_thinking(msg))
91
115
  end
92
116
  end
93
117
 
118
+ def format_content(content)
119
+ OpenAI::Media.format_content(content)
120
+ end
121
+
94
122
  def format_role(role)
95
123
  case role
96
124
  when :system
@@ -9,12 +9,12 @@ module RubyLLM
9
9
  module Images
10
10
  module_function
11
11
 
12
- def images_url
12
+ def images_url(with: nil, mask: nil) # rubocop:disable Lint/UnusedMethodArgument
13
13
  'chat/completions'
14
14
  end
15
15
 
16
- def render_image_payload(prompt, model:, size:)
17
- RubyLLM.logger.debug "Ignoring size #{size}. OpenRouter image generation does not support size parameter."
16
+ def render_image_payload(prompt, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Lint/UnusedMethodArgument,Metrics/ParameterLists
17
+ RubyLLM.logger.debug { "Ignoring size #{size}. OpenRouter image generation does not support size parameter." }
18
18
  {
19
19
  model: model,
20
20
  messages: [
@@ -23,7 +23,7 @@ module RubyLLM
23
23
  pricing_types = {
24
24
  prompt: :input_per_million,
25
25
  completion: :output_per_million,
26
- input_cache_read: :cached_input_per_million,
26
+ input_cache_read: :cache_read_input_per_million,
27
27
  internal_reasoning: :reasoning_output_per_million
28
28
  }
29
29
 
@@ -13,7 +13,6 @@ module RubyLLM
13
13
 
14
14
  def build_chunk(data)
15
15
  usage = data['usage'] || {}
16
- cached_tokens = usage.dig('prompt_tokens_details', 'cached_tokens')
17
16
  delta = data.dig('choices', 0, 'delta') || {}
18
17
 
19
18
  Chunk.new(
@@ -25,11 +24,11 @@ module RubyLLM
25
24
  signature: extract_thinking_signature(delta)
26
25
  ),
27
26
  tool_calls: OpenAI::Tools.parse_tool_calls(delta['tool_calls'], parse_arguments: false),
28
- input_tokens: usage['prompt_tokens'],
29
- output_tokens: usage['completion_tokens'],
30
- cached_tokens: cached_tokens,
31
- cache_creation_tokens: 0,
32
- thinking_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens')
27
+ input_tokens: OpenRouter::Chat.input_tokens(usage),
28
+ output_tokens: OpenRouter::Chat.output_tokens(usage),
29
+ cached_tokens: OpenRouter::Chat.cache_read_tokens(usage),
30
+ cache_creation_tokens: OpenRouter::Chat.cache_write_tokens(usage),
31
+ thinking_tokens: OpenRouter::Chat.thinking_tokens(usage)
33
32
  )
34
33
  end
35
34
 
@@ -49,6 +49,10 @@ module RubyLLM
49
49
  end
50
50
 
51
51
  class << self
52
+ def configuration_options
53
+ %i[openrouter_api_key openrouter_api_base]
54
+ end
55
+
52
56
  def configuration_requirements
53
57
  %i[openrouter_api_key]
54
58
  end
@@ -3,63 +3,55 @@
3
3
  module RubyLLM
4
4
  module Providers
5
5
  class Perplexity
6
- # Determines capabilities and pricing for Perplexity models
6
+ # Provider-level capability checks and narrow registry fallbacks.
7
7
  module Capabilities
8
8
  module_function
9
9
 
10
- def context_window_for(model_id)
11
- case model_id
12
- when /sonar-pro/ then 200_000
13
- else 128_000
14
- end
15
- end
10
+ PRICES = {
11
+ sonar: { input: 1.0, output: 1.0 },
12
+ sonar_pro: { input: 3.0, output: 15.0 },
13
+ sonar_reasoning: { input: 1.0, output: 5.0 },
14
+ sonar_reasoning_pro: { input: 2.0, output: 8.0 },
15
+ sonar_deep_research: {
16
+ input: 2.0,
17
+ output: 8.0,
18
+ reasoning_output: 3.0
19
+ }
20
+ }.freeze
16
21
 
17
- def max_tokens_for(model_id)
18
- case model_id
19
- when /sonar-(?:pro|reasoning-pro)/ then 8_192
20
- else 4_096
21
- end
22
+ def supports_tool_choice?(_model_id)
23
+ false
22
24
  end
23
25
 
24
- def input_price_for(model_id)
25
- PRICES.dig(model_family(model_id), :input) || 1.0
26
+ def supports_tool_parallel_control?(_model_id)
27
+ false
26
28
  end
27
29
 
28
- def output_price_for(model_id)
29
- PRICES.dig(model_family(model_id), :output) || 1.0
30
+ def context_window_for(model_id)
31
+ model_id.match?(/sonar-pro/) ? 200_000 : 128_000
30
32
  end
31
33
 
32
- def supports_vision?(model_id)
33
- case model_id
34
- when /sonar-reasoning-pro/, /sonar-reasoning/, /sonar-pro/, /sonar/ then true
35
- else false
36
- end
34
+ def max_tokens_for(model_id)
35
+ model_id.match?(/sonar-(?:pro|reasoning-pro)/) ? 8_192 : 4_096
37
36
  end
38
37
 
39
- def supports_functions?(_model_id)
40
- false
38
+ def critical_capabilities_for(model_id)
39
+ capabilities = []
40
+ capabilities << 'vision' if model_id.match?(/sonar(?:-pro|-reasoning(?:-pro)?)?$/)
41
+ capabilities << 'reasoning' if model_id.match?(/reasoning|deep-research/)
42
+ capabilities
41
43
  end
42
44
 
43
- def supports_json_mode?(_model_id)
44
- true
45
- end
45
+ def pricing_for(model_id)
46
+ prices = PRICES.fetch(model_family(model_id), { input: 1.0, output: 1.0 })
46
47
 
47
- def format_display_name(model_id)
48
- case model_id
49
- when 'sonar' then 'Sonar'
50
- when 'sonar-pro' then 'Sonar Pro'
51
- when 'sonar-reasoning' then 'Sonar Reasoning'
52
- when 'sonar-reasoning-pro' then 'Sonar Reasoning Pro'
53
- when 'sonar-deep-research' then 'Sonar Deep Research'
54
- else
55
- model_id.split('-')
56
- .map(&:capitalize)
57
- .join(' ')
58
- end
59
- end
48
+ standard = {
49
+ input_per_million: prices[:input],
50
+ output_per_million: prices[:output]
51
+ }
52
+ standard[:reasoning_output_per_million] = prices[:reasoning_output] if prices[:reasoning_output]
60
53
 
61
- def model_type(_model_id)
62
- 'chat'
54
+ { text_tokens: { standard: standard } }
63
55
  end
64
56
 
65
57
  def model_family(model_id)
@@ -73,64 +65,7 @@ module RubyLLM
73
65
  end
74
66
  end
75
67
 
76
- def modalities_for(_model_id)
77
- {
78
- input: ['text'],
79
- output: ['text']
80
- }
81
- end
82
-
83
- def capabilities_for(model_id)
84
- capabilities = %w[streaming json_mode]
85
- capabilities << 'vision' if supports_vision?(model_id)
86
- capabilities
87
- end
88
-
89
- def pricing_for(model_id)
90
- family = model_family(model_id)
91
- prices = PRICES.fetch(family, { input: 1.0, output: 1.0 })
92
-
93
- standard_pricing = {
94
- input_per_million: prices[:input],
95
- output_per_million: prices[:output]
96
- }
97
-
98
- standard_pricing[:citation_per_million] = prices[:citation] if prices[:citation]
99
- standard_pricing[:reasoning_per_million] = prices[:reasoning] if prices[:reasoning]
100
- standard_pricing[:search_per_thousand] = prices[:search_queries] if prices[:search_queries]
101
-
102
- {
103
- text_tokens: {
104
- standard: standard_pricing
105
- }
106
- }
107
- end
108
-
109
- PRICES = {
110
- sonar: {
111
- input: 1.0,
112
- output: 1.0
113
- },
114
- sonar_pro: {
115
- input: 3.0,
116
- output: 15.0
117
- },
118
- sonar_reasoning: {
119
- input: 1.0,
120
- output: 5.0
121
- },
122
- sonar_reasoning_pro: {
123
- input: 2.0,
124
- output: 8.0
125
- },
126
- sonar_deep_research: {
127
- input: 2.0,
128
- output: 8.0,
129
- citation: 2.0,
130
- reasoning: 3.0,
131
- search_queries: 5.0
132
- }
133
- }.freeze
68
+ module_function :context_window_for, :max_tokens_for, :critical_capabilities_for, :pricing_for, :model_family
134
69
  end
135
70
  end
136
71
  end
@@ -10,6 +10,17 @@ module RubyLLM
10
10
  def format_role(role)
11
11
  role.to_s
12
12
  end
13
+
14
+ def format_messages(messages)
15
+ messages.map do |msg|
16
+ {
17
+ role: format_role(msg.role),
18
+ content: Perplexity::Media.format_content(msg.content),
19
+ tool_calls: OpenAI::Tools.format_tool_calls(msg.tool_calls),
20
+ tool_call_id: msg.tool_call_id
21
+ }.compact.merge(OpenAI::Chat.format_thinking(msg))
22
+ end
23
+ end
13
24
  end
14
25
  end
15
26
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Perplexity
6
+ # Handles Perplexity Sonar media content.
7
+ module Media
8
+ module_function
9
+
10
+ SUPPORTED_DOCUMENT_EXTENSIONS = %w[pdf doc docx txt rtf].freeze
11
+
12
+ def format_content(content) # rubocop:disable Metrics/PerceivedComplexity
13
+ if content.is_a?(RubyLLM::Content::Raw)
14
+ value = content.value
15
+ return value.is_a?(Hash) ? value.to_json : value
16
+ end
17
+ return content.to_json if content.is_a?(Hash) || content.is_a?(Array)
18
+ return content unless content.is_a?(Content)
19
+
20
+ parts = []
21
+ parts << OpenAI::Media.format_text(content.text) if content.text
22
+
23
+ content.attachments.each do |attachment|
24
+ case attachment.type
25
+ when :image
26
+ parts << OpenAI::Media.format_image(attachment)
27
+ when :pdf, :document
28
+ parts << format_document(attachment)
29
+ when :text
30
+ parts << format_text_attachment(attachment)
31
+ else
32
+ raise UnsupportedAttachmentError, attachment.mime_type
33
+ end
34
+ end
35
+
36
+ parts
37
+ end
38
+
39
+ def format_document(attachment)
40
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_file?(attachment)
41
+
42
+ {
43
+ type: 'file_url',
44
+ file_url: {
45
+ url: attachment.url? ? attachment.source.to_s : attachment.encoded
46
+ }
47
+ }
48
+ end
49
+
50
+ def format_text_attachment(attachment)
51
+ OpenAI::Media.format_text_file(attachment)
52
+ end
53
+
54
+ def supported_file?(attachment)
55
+ return true if attachment.pdf?
56
+
57
+ SUPPORTED_DOCUMENT_EXTENSIONS.include?(attachment.extension)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end