dify_llm 1.9.1 → 1.14.1

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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -8
  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 +2 -2
  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 +37 -17
  50. data/lib/generators/ruby_llm/install/install_generator.rb +22 -18
  51. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +1 -1
  52. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +4 -1
  53. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +4 -10
  54. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +2 -1
  55. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -2
  56. data/lib/generators/ruby_llm/schema/schema_generator.rb +26 -0
  57. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +2 -0
  58. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +9 -0
  59. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +13 -0
  60. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +13 -0
  61. data/lib/generators/ruby_llm/tool/tool_generator.rb +96 -0
  62. data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +19 -0
  63. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +50 -0
  64. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  65. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  66. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +1 -1
  67. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +2 -4
  68. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  69. data/lib/ruby_llm/active_record/acts_as.rb +10 -4
  70. data/lib/ruby_llm/active_record/acts_as_legacy.rb +132 -27
  71. data/lib/ruby_llm/active_record/chat_methods.rb +132 -28
  72. data/lib/ruby_llm/active_record/message_methods.rb +58 -8
  73. data/lib/ruby_llm/active_record/model_methods.rb +1 -1
  74. data/lib/ruby_llm/active_record/payload_helpers.rb +26 -0
  75. data/lib/ruby_llm/active_record/tool_call_methods.rb +15 -0
  76. data/lib/ruby_llm/agent.rb +365 -0
  77. data/lib/ruby_llm/aliases.json +199 -62
  78. data/lib/ruby_llm/attachment.rb +15 -4
  79. data/lib/ruby_llm/chat.rb +150 -22
  80. data/lib/ruby_llm/configuration.rb +65 -65
  81. data/lib/ruby_llm/connection.rb +11 -7
  82. data/lib/ruby_llm/content.rb +6 -2
  83. data/lib/ruby_llm/error.rb +37 -1
  84. data/lib/ruby_llm/message.rb +43 -15
  85. data/lib/ruby_llm/model/info.rb +15 -13
  86. data/lib/ruby_llm/models.json +37560 -14094
  87. data/lib/ruby_llm/models.rb +321 -38
  88. data/lib/ruby_llm/models_schema.json +2 -2
  89. data/lib/ruby_llm/provider.rb +26 -4
  90. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -119
  91. data/lib/ruby_llm/providers/anthropic/chat.rb +149 -17
  92. data/lib/ruby_llm/providers/anthropic/media.rb +2 -2
  93. data/lib/ruby_llm/providers/anthropic/models.rb +3 -9
  94. data/lib/ruby_llm/providers/anthropic/streaming.rb +25 -1
  95. data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
  96. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  97. data/lib/ruby_llm/providers/azure/chat.rb +29 -0
  98. data/lib/ruby_llm/providers/azure/embeddings.rb +24 -0
  99. data/lib/ruby_llm/providers/azure/media.rb +45 -0
  100. data/lib/ruby_llm/providers/azure/models.rb +14 -0
  101. data/lib/ruby_llm/providers/azure.rb +148 -0
  102. data/lib/ruby_llm/providers/bedrock/auth.rb +122 -0
  103. data/lib/ruby_llm/providers/bedrock/chat.rb +357 -28
  104. data/lib/ruby_llm/providers/bedrock/media.rb +62 -33
  105. data/lib/ruby_llm/providers/bedrock/models.rb +107 -62
  106. data/lib/ruby_llm/providers/bedrock/streaming.rb +309 -8
  107. data/lib/ruby_llm/providers/bedrock.rb +69 -52
  108. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -114
  109. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  110. data/lib/ruby_llm/providers/dify/chat.rb +82 -7
  111. data/lib/ruby_llm/providers/dify/media.rb +2 -2
  112. data/lib/ruby_llm/providers/dify/streaming.rb +26 -4
  113. data/lib/ruby_llm/providers/dify.rb +4 -0
  114. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -207
  115. data/lib/ruby_llm/providers/gemini/chat.rb +88 -6
  116. data/lib/ruby_llm/providers/gemini/images.rb +1 -1
  117. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  118. data/lib/ruby_llm/providers/gemini/streaming.rb +34 -2
  119. data/lib/ruby_llm/providers/gemini/tools.rb +35 -3
  120. data/lib/ruby_llm/providers/gemini.rb +4 -0
  121. data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
  122. data/lib/ruby_llm/providers/gpustack/chat.rb +1 -1
  123. data/lib/ruby_llm/providers/gpustack.rb +8 -0
  124. data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
  125. data/lib/ruby_llm/providers/mistral/chat.rb +59 -1
  126. data/lib/ruby_llm/providers/mistral.rb +4 -0
  127. data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
  128. data/lib/ruby_llm/providers/ollama/chat.rb +1 -1
  129. data/lib/ruby_llm/providers/ollama.rb +11 -1
  130. data/lib/ruby_llm/providers/openai/capabilities.rb +96 -192
  131. data/lib/ruby_llm/providers/openai/chat.rb +101 -7
  132. data/lib/ruby_llm/providers/openai/media.rb +5 -2
  133. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  134. data/lib/ruby_llm/providers/openai/streaming.rb +11 -3
  135. data/lib/ruby_llm/providers/openai/temperature.rb +28 -0
  136. data/lib/ruby_llm/providers/openai/tools.rb +27 -2
  137. data/lib/ruby_llm/providers/openai.rb +11 -1
  138. data/lib/ruby_llm/providers/openrouter/chat.rb +168 -0
  139. data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
  140. data/lib/ruby_llm/providers/openrouter/streaming.rb +74 -0
  141. data/lib/ruby_llm/providers/openrouter.rb +37 -1
  142. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  143. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  144. data/lib/ruby_llm/providers/perplexity.rb +4 -0
  145. data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
  146. data/lib/ruby_llm/providers/vertexai.rb +23 -7
  147. data/lib/ruby_llm/providers/xai/chat.rb +15 -0
  148. data/lib/ruby_llm/providers/xai/models.rb +75 -0
  149. data/lib/ruby_llm/providers/xai.rb +32 -0
  150. data/lib/ruby_llm/stream_accumulator.rb +120 -18
  151. data/lib/ruby_llm/streaming.rb +82 -60
  152. data/lib/ruby_llm/thinking.rb +49 -0
  153. data/lib/ruby_llm/tokens.rb +47 -0
  154. data/lib/ruby_llm/tool.rb +49 -4
  155. data/lib/ruby_llm/tool_call.rb +6 -3
  156. data/lib/ruby_llm/version.rb +1 -1
  157. data/lib/ruby_llm.rb +14 -8
  158. data/lib/tasks/models.rake +62 -23
  159. data/lib/tasks/release.rake +1 -1
  160. data/lib/tasks/ruby_llm.rake +9 -1
  161. data/lib/tasks/vcr.rake +33 -1
  162. metadata +67 -16
  163. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
  164. data/lib/ruby_llm/providers/bedrock/capabilities.rb +0 -167
  165. data/lib/ruby_llm/providers/bedrock/signing.rb +0 -831
  166. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -51
  167. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -71
  168. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -67
  169. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -80
  170. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +0 -78
@@ -53,7 +53,7 @@ module RubyLLM
53
53
  return nil unless tool_calls&.any?
54
54
 
55
55
  tool_calls.map do |_, tc|
56
- {
56
+ call = {
57
57
  id: tc.id,
58
58
  type: 'function',
59
59
  function: {
@@ -61,6 +61,12 @@ module RubyLLM
61
61
  arguments: JSON.generate(tc.arguments)
62
62
  }
63
63
  }
64
+ if tc.thought_signature
65
+ call[:extra_content] = {
66
+ google: { thought_signature: tc.thought_signature }
67
+ }
68
+ end
69
+ call
64
70
  end
65
71
  end
66
72
 
@@ -87,11 +93,30 @@ module RubyLLM
87
93
  parse_tool_call_arguments(tc)
88
94
  else
89
95
  tc.dig('function', 'arguments')
90
- end
96
+ end,
97
+ thought_signature: extract_tool_call_thought_signature(tc)
91
98
  )
92
99
  ]
93
100
  end
94
101
  end
102
+
103
+ def build_tool_choice(tool_choice)
104
+ case tool_choice
105
+ when :auto, :none, :required
106
+ tool_choice
107
+ else
108
+ {
109
+ type: 'function',
110
+ function: {
111
+ name: tool_choice
112
+ }
113
+ }
114
+ end
115
+ end
116
+
117
+ def extract_tool_call_thought_signature(tool_call)
118
+ tool_call.dig('extra_content', 'google', 'thought_signature')
119
+ end
95
120
  end
96
121
  end
97
122
  end
@@ -27,7 +27,7 @@ module RubyLLM
27
27
  end
28
28
 
29
29
  def maybe_normalize_temperature(temperature, model)
30
- OpenAI::Capabilities.normalize_temperature(temperature, model.id)
30
+ OpenAI::Temperature.normalize(temperature, model.id)
31
31
  end
32
32
 
33
33
  class << self
@@ -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
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenRouter
6
+ # Chat methods of the OpenRouter API integration
7
+ module Chat
8
+ module_function
9
+
10
+ # rubocop:disable Metrics/ParameterLists,Metrics/PerceivedComplexity
11
+ def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil,
12
+ thinking: nil, tool_prefs: nil)
13
+ tool_prefs ||= {}
14
+ payload = {
15
+ model: model.id,
16
+ messages: format_messages(messages),
17
+ stream: stream
18
+ }
19
+
20
+ payload[:temperature] = temperature unless temperature.nil?
21
+ if tools.any?
22
+ payload[:tools] = tools.map { |_, tool| OpenAI::Tools.tool_for(tool) }
23
+ payload[:tool_choice] = OpenAI::Tools.build_tool_choice(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
24
+ payload[:parallel_tool_calls] = tool_prefs[:calls] == :many unless tool_prefs[:calls].nil?
25
+ end
26
+
27
+ if schema
28
+ schema_name = schema[:name]
29
+ schema_def = RubyLLM::Utils.deep_dup(schema[:schema])
30
+ if schema_def.is_a?(Hash)
31
+ schema_def.delete(:strict)
32
+ schema_def.delete('strict')
33
+ end
34
+ strict = schema[:strict]
35
+ payload[:response_format] = {
36
+ type: 'json_schema',
37
+ json_schema: {
38
+ name: schema_name,
39
+ schema: schema_def,
40
+ strict: strict
41
+ }
42
+ }
43
+ end
44
+
45
+ reasoning = build_reasoning(thinking)
46
+ payload[:reasoning] = reasoning if reasoning
47
+
48
+ payload[:stream_options] = { include_usage: true } if stream
49
+ payload
50
+ end
51
+ # rubocop:enable Metrics/ParameterLists,Metrics/PerceivedComplexity
52
+
53
+ def parse_completion_response(response)
54
+ data = response.body
55
+ return if data.empty?
56
+
57
+ raise Error.new(response, data.dig('error', 'message')) if data.dig('error', 'message')
58
+
59
+ message_data = data.dig('choices', 0, 'message')
60
+ return unless message_data
61
+
62
+ usage = data['usage'] || {}
63
+ cached_tokens = usage.dig('prompt_tokens_details', 'cached_tokens')
64
+ thinking_tokens = usage.dig('completion_tokens_details', 'reasoning_tokens')
65
+ thinking_text = extract_thinking_text(message_data)
66
+ thinking_signature = extract_thinking_signature(message_data)
67
+
68
+ Message.new(
69
+ role: :assistant,
70
+ content: message_data['content'],
71
+ thinking: Thinking.build(text: thinking_text, signature: thinking_signature),
72
+ 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,
77
+ thinking_tokens: thinking_tokens,
78
+ model_id: data['model'],
79
+ raw: response
80
+ )
81
+ end
82
+
83
+ def format_messages(messages)
84
+ messages.map do |msg|
85
+ {
86
+ role: format_role(msg.role),
87
+ content: OpenAI::Media.format_content(msg.content),
88
+ tool_calls: OpenAI::Tools.format_tool_calls(msg.tool_calls),
89
+ tool_call_id: msg.tool_call_id
90
+ }.compact.merge(format_thinking(msg))
91
+ end
92
+ end
93
+
94
+ def format_role(role)
95
+ case role
96
+ when :system
97
+ @config.openai_use_system_role ? 'system' : 'developer'
98
+ else
99
+ role.to_s
100
+ end
101
+ end
102
+
103
+ def build_reasoning(thinking)
104
+ return nil unless thinking&.enabled?
105
+
106
+ reasoning = {}
107
+ reasoning[:effort] = thinking.effort if thinking.respond_to?(:effort) && thinking.effort
108
+ reasoning[:max_tokens] = thinking.budget if thinking.respond_to?(:budget) && thinking.budget
109
+ reasoning[:enabled] = true if reasoning.empty?
110
+ reasoning
111
+ end
112
+
113
+ def format_thinking(msg)
114
+ thinking = msg.thinking
115
+ return {} unless thinking && msg.role == :assistant
116
+
117
+ details = []
118
+ if thinking.text
119
+ details << {
120
+ type: 'reasoning.text',
121
+ text: thinking.text,
122
+ signature: thinking.signature
123
+ }.compact
124
+ elsif thinking.signature
125
+ details << {
126
+ type: 'reasoning.encrypted',
127
+ data: thinking.signature
128
+ }
129
+ end
130
+
131
+ details.empty? ? {} : { reasoning_details: details }
132
+ end
133
+
134
+ def extract_thinking_text(message_data)
135
+ candidate = message_data['reasoning']
136
+ return candidate if candidate.is_a?(String)
137
+
138
+ details = message_data['reasoning_details']
139
+ return nil unless details.is_a?(Array)
140
+
141
+ text = details.filter_map do |detail|
142
+ case detail['type']
143
+ when 'reasoning.text'
144
+ detail['text']
145
+ when 'reasoning.summary'
146
+ detail['summary']
147
+ end
148
+ end.join
149
+
150
+ text.empty? ? nil : text
151
+ end
152
+
153
+ def extract_thinking_signature(message_data)
154
+ details = message_data['reasoning_details']
155
+ return nil unless details.is_a?(Array)
156
+
157
+ signature = details.filter_map do |detail|
158
+ detail['signature'] if detail['signature'].is_a?(String)
159
+ end.first
160
+ return signature if signature
161
+
162
+ encrypted = details.find { |detail| detail['type'] == 'reasoning.encrypted' && detail['data'].is_a?(String) }
163
+ encrypted&.dig('data')
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenRouter
6
+ # Image generation methods for the OpenRouter API integration.
7
+ # OpenRouter uses the chat completions endpoint for image generation
8
+ # instead of a dedicated images endpoint.
9
+ module Images
10
+ module_function
11
+
12
+ def images_url
13
+ 'chat/completions'
14
+ end
15
+
16
+ def render_image_payload(prompt, model:, size:)
17
+ RubyLLM.logger.debug { "Ignoring size #{size}. OpenRouter image generation does not support size parameter." }
18
+ {
19
+ model: model,
20
+ messages: [
21
+ {
22
+ role: 'user',
23
+ content: prompt
24
+ }
25
+ ],
26
+ modalities: %w[image text]
27
+ }
28
+ end
29
+
30
+ def parse_image_response(response, model:)
31
+ data = response.body
32
+ message = data.dig('choices', 0, 'message')
33
+
34
+ unless message&.key?('images') && message['images']&.any?
35
+ raise Error.new(nil, 'Unexpected response format from OpenRouter image generation API')
36
+ end
37
+
38
+ image_data = message['images'].first
39
+ image_url = image_data.dig('image_url', 'url') || image_data['url']
40
+
41
+ raise Error.new(nil, 'No image URL found in OpenRouter response') unless image_url
42
+
43
+ build_image_from_url(image_url, model)
44
+ end
45
+
46
+ def build_image_from_url(image_url, model)
47
+ if image_url.start_with?('data:')
48
+ # Parse data URL format: data:image/png;base64,<data>
49
+ match = image_url.match(/^data:([^;]+);base64,(.+)$/)
50
+ raise Error.new(nil, 'Invalid data URL format from OpenRouter') unless match
51
+
52
+ Image.new(
53
+ data: match[2],
54
+ mime_type: match[1],
55
+ model_id: model
56
+ )
57
+ else
58
+ # Regular URL
59
+ Image.new(
60
+ url: image_url,
61
+ mime_type: 'image/png',
62
+ model_id: model
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenRouter
6
+ # Streaming methods of the OpenRouter API integration
7
+ module Streaming
8
+ module_function
9
+
10
+ def stream_url
11
+ completion_url
12
+ end
13
+
14
+ def build_chunk(data)
15
+ usage = data['usage'] || {}
16
+ cached_tokens = usage.dig('prompt_tokens_details', 'cached_tokens')
17
+ delta = data.dig('choices', 0, 'delta') || {}
18
+
19
+ Chunk.new(
20
+ role: :assistant,
21
+ model_id: data['model'],
22
+ content: delta['content'],
23
+ thinking: Thinking.build(
24
+ text: extract_thinking_text(delta),
25
+ signature: extract_thinking_signature(delta)
26
+ ),
27
+ 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')
33
+ )
34
+ end
35
+
36
+ def parse_streaming_error(data)
37
+ OpenAI::Streaming.parse_streaming_error(data)
38
+ end
39
+
40
+ def extract_thinking_text(delta)
41
+ candidate = delta['reasoning']
42
+ return candidate if candidate.is_a?(String)
43
+
44
+ details = delta['reasoning_details']
45
+ return nil unless details.is_a?(Array)
46
+
47
+ text = details.filter_map do |detail|
48
+ case detail['type']
49
+ when 'reasoning.text'
50
+ detail['text']
51
+ when 'reasoning.summary'
52
+ detail['summary']
53
+ end
54
+ end.join
55
+
56
+ text.empty? ? nil : text
57
+ end
58
+
59
+ def extract_thinking_signature(delta)
60
+ details = delta['reasoning_details']
61
+ return nil unless details.is_a?(Array)
62
+
63
+ signature = details.filter_map do |detail|
64
+ detail['signature'] if detail['signature'].is_a?(String)
65
+ end.first
66
+ return signature if signature
67
+
68
+ encrypted = details.find { |detail| detail['type'] == 'reasoning.encrypted' && detail['data'].is_a?(String) }
69
+ encrypted&.dig('data')
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -4,10 +4,13 @@ module RubyLLM
4
4
  module Providers
5
5
  # OpenRouter API integration.
6
6
  class OpenRouter < OpenAI
7
+ include OpenRouter::Chat
7
8
  include OpenRouter::Models
9
+ include OpenRouter::Streaming
10
+ include OpenRouter::Images
8
11
 
9
12
  def api_base
10
- 'https://openrouter.ai/api/v1'
13
+ @config.openrouter_api_base || 'https://openrouter.ai/api/v1'
11
14
  end
12
15
 
13
16
  def headers
@@ -16,7 +19,40 @@ module RubyLLM
16
19
  }
17
20
  end
18
21
 
22
+ def parse_error(response)
23
+ return if response.body.empty?
24
+
25
+ body = try_parse_json(response.body)
26
+ case body
27
+ when Hash
28
+ parse_error_part_message body
29
+ when Array
30
+ body.map do |part|
31
+ parse_error_part_message part
32
+ end.join('. ')
33
+ else
34
+ body
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def parse_error_part_message(part)
41
+ message = part.dig('error', 'message')
42
+ raw = try_parse_json(part.dig('error', 'metadata', 'raw'))
43
+ return message unless raw.is_a?(Hash)
44
+
45
+ raw_message = raw.dig('error', 'message')
46
+ return [message, raw_message].compact.join(' - ') if raw_message
47
+
48
+ message
49
+ end
50
+
19
51
  class << self
52
+ def configuration_options
53
+ %i[openrouter_api_key openrouter_api_base]
54
+ end
55
+
20
56
  def configuration_requirements
21
57
  %i[openrouter_api_key]
22
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
@@ -5,33 +5,31 @@ module RubyLLM
5
5
  class Perplexity
6
6
  # Models methods of the Perplexity API integration
7
7
  module Models
8
+ MODEL_IDS = %w[
9
+ sonar
10
+ sonar-pro
11
+ sonar-reasoning
12
+ sonar-reasoning-pro
13
+ sonar-deep-research
14
+ ].freeze
15
+
8
16
  def list_models(**)
9
17
  slug = 'perplexity'
10
- capabilities = Perplexity::Capabilities
11
- parse_list_models_response(nil, slug, capabilities)
18
+ parse_list_models_response(nil, slug, Perplexity::Capabilities)
12
19
  end
13
20
 
14
21
  def parse_list_models_response(_response, slug, capabilities)
15
- [
16
- create_model_info('sonar', slug, capabilities),
17
- create_model_info('sonar-pro', slug, capabilities),
18
- create_model_info('sonar-reasoning', slug, capabilities),
19
- create_model_info('sonar-reasoning-pro', slug, capabilities),
20
- create_model_info('sonar-deep-research', slug, capabilities)
21
- ]
22
+ MODEL_IDS.map { |id| create_model_info(id, slug, capabilities) }
22
23
  end
23
24
 
24
25
  def create_model_info(id, slug, capabilities)
25
26
  Model::Info.new(
26
27
  id: id,
27
- name: capabilities.format_display_name(id),
28
+ name: id,
28
29
  provider: slug,
29
- family: capabilities.model_family(id).to_s,
30
- created_at: Time.now,
31
30
  context_window: capabilities.context_window_for(id),
32
31
  max_output_tokens: capabilities.max_tokens_for(id),
33
- modalities: capabilities.modalities_for(id),
34
- capabilities: capabilities.capabilities_for(id),
32
+ capabilities: capabilities.critical_capabilities_for(id),
35
33
  pricing: capabilities.pricing_for(id),
36
34
  metadata: {}
37
35
  )
@@ -23,6 +23,10 @@ module RubyLLM
23
23
  Perplexity::Capabilities
24
24
  end
25
25
 
26
+ def configuration_options
27
+ %i[perplexity_api_key]
28
+ end
29
+
26
30
  def configuration_requirements
27
31
  %i[perplexity_api_key]
28
32
  end
@@ -56,7 +56,7 @@ module RubyLLM
56
56
 
57
57
  all_models
58
58
  rescue StandardError => e
59
- RubyLLM.logger.debug "Error fetching Vertex AI models: #{e.message}"
59
+ RubyLLM.logger.debug { "Error fetching Vertex AI models: #{e.message}" }
60
60
  build_known_models
61
61
  end
62
62