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.
- checksums.yaml +4 -4
- data/README.md +27 -8
- data/lib/generators/ruby_llm/agent/agent_generator.rb +36 -0
- data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -0
- data/lib/generators/ruby_llm/agent/templates/instructions.txt.erb.tt +0 -0
- data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +110 -41
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +14 -15
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -11
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
- data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +19 -7
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +5 -3
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -7
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +5 -7
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +11 -12
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +27 -17
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -4
- data/lib/generators/ruby_llm/generator_helpers.rb +37 -17
- data/lib/generators/ruby_llm/install/install_generator.rb +22 -18
- data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +1 -1
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +4 -1
- data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +4 -10
- data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +2 -1
- data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -2
- data/lib/generators/ruby_llm/schema/schema_generator.rb +26 -0
- data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +2 -0
- data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +9 -0
- data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +13 -0
- data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +13 -0
- data/lib/generators/ruby_llm/tool/tool_generator.rb +96 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +19 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +50 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +1 -1
- data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +2 -4
- data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
- data/lib/ruby_llm/active_record/acts_as.rb +10 -4
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +132 -27
- data/lib/ruby_llm/active_record/chat_methods.rb +132 -28
- data/lib/ruby_llm/active_record/message_methods.rb +58 -8
- data/lib/ruby_llm/active_record/model_methods.rb +1 -1
- data/lib/ruby_llm/active_record/payload_helpers.rb +26 -0
- data/lib/ruby_llm/active_record/tool_call_methods.rb +15 -0
- data/lib/ruby_llm/agent.rb +365 -0
- data/lib/ruby_llm/aliases.json +199 -62
- data/lib/ruby_llm/attachment.rb +15 -4
- data/lib/ruby_llm/chat.rb +150 -22
- data/lib/ruby_llm/configuration.rb +65 -65
- data/lib/ruby_llm/connection.rb +11 -7
- data/lib/ruby_llm/content.rb +6 -2
- data/lib/ruby_llm/error.rb +37 -1
- data/lib/ruby_llm/message.rb +43 -15
- data/lib/ruby_llm/model/info.rb +15 -13
- data/lib/ruby_llm/models.json +37560 -14094
- data/lib/ruby_llm/models.rb +321 -38
- data/lib/ruby_llm/models_schema.json +2 -2
- data/lib/ruby_llm/provider.rb +26 -4
- data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -119
- data/lib/ruby_llm/providers/anthropic/chat.rb +149 -17
- data/lib/ruby_llm/providers/anthropic/media.rb +2 -2
- data/lib/ruby_llm/providers/anthropic/models.rb +3 -9
- data/lib/ruby_llm/providers/anthropic/streaming.rb +25 -1
- data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
- data/lib/ruby_llm/providers/anthropic.rb +5 -1
- data/lib/ruby_llm/providers/azure/chat.rb +29 -0
- data/lib/ruby_llm/providers/azure/embeddings.rb +24 -0
- data/lib/ruby_llm/providers/azure/media.rb +45 -0
- data/lib/ruby_llm/providers/azure/models.rb +14 -0
- data/lib/ruby_llm/providers/azure.rb +148 -0
- data/lib/ruby_llm/providers/bedrock/auth.rb +122 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +357 -28
- data/lib/ruby_llm/providers/bedrock/media.rb +62 -33
- data/lib/ruby_llm/providers/bedrock/models.rb +107 -62
- data/lib/ruby_llm/providers/bedrock/streaming.rb +309 -8
- data/lib/ruby_llm/providers/bedrock.rb +69 -52
- data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -114
- data/lib/ruby_llm/providers/deepseek.rb +5 -1
- data/lib/ruby_llm/providers/dify/chat.rb +82 -7
- data/lib/ruby_llm/providers/dify/media.rb +2 -2
- data/lib/ruby_llm/providers/dify/streaming.rb +26 -4
- data/lib/ruby_llm/providers/dify.rb +4 -0
- data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -207
- data/lib/ruby_llm/providers/gemini/chat.rb +88 -6
- data/lib/ruby_llm/providers/gemini/images.rb +1 -1
- data/lib/ruby_llm/providers/gemini/models.rb +2 -4
- data/lib/ruby_llm/providers/gemini/streaming.rb +34 -2
- data/lib/ruby_llm/providers/gemini/tools.rb +35 -3
- data/lib/ruby_llm/providers/gemini.rb +4 -0
- data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
- data/lib/ruby_llm/providers/gpustack/chat.rb +1 -1
- data/lib/ruby_llm/providers/gpustack.rb +8 -0
- data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
- data/lib/ruby_llm/providers/mistral/chat.rb +59 -1
- data/lib/ruby_llm/providers/mistral.rb +4 -0
- data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
- data/lib/ruby_llm/providers/ollama/chat.rb +1 -1
- data/lib/ruby_llm/providers/ollama.rb +11 -1
- data/lib/ruby_llm/providers/openai/capabilities.rb +96 -192
- data/lib/ruby_llm/providers/openai/chat.rb +101 -7
- data/lib/ruby_llm/providers/openai/media.rb +5 -2
- data/lib/ruby_llm/providers/openai/models.rb +2 -4
- data/lib/ruby_llm/providers/openai/streaming.rb +11 -3
- data/lib/ruby_llm/providers/openai/temperature.rb +28 -0
- data/lib/ruby_llm/providers/openai/tools.rb +27 -2
- data/lib/ruby_llm/providers/openai.rb +11 -1
- data/lib/ruby_llm/providers/openrouter/chat.rb +168 -0
- data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
- data/lib/ruby_llm/providers/openrouter/streaming.rb +74 -0
- data/lib/ruby_llm/providers/openrouter.rb +37 -1
- data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
- data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
- data/lib/ruby_llm/providers/perplexity.rb +4 -0
- data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
- data/lib/ruby_llm/providers/vertexai.rb +23 -7
- data/lib/ruby_llm/providers/xai/chat.rb +15 -0
- data/lib/ruby_llm/providers/xai/models.rb +75 -0
- data/lib/ruby_llm/providers/xai.rb +32 -0
- data/lib/ruby_llm/stream_accumulator.rb +120 -18
- data/lib/ruby_llm/streaming.rb +82 -60
- data/lib/ruby_llm/thinking.rb +49 -0
- data/lib/ruby_llm/tokens.rb +47 -0
- data/lib/ruby_llm/tool.rb +49 -4
- data/lib/ruby_llm/tool_call.rb +6 -3
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm.rb +14 -8
- data/lib/tasks/models.rake +62 -23
- data/lib/tasks/release.rake +1 -1
- data/lib/tasks/ruby_llm.rake +9 -1
- data/lib/tasks/vcr.rake +33 -1
- metadata +67 -16
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
- data/lib/ruby_llm/providers/bedrock/capabilities.rb +0 -167
- data/lib/ruby_llm/providers/bedrock/signing.rb +0 -831
- data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -51
- data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -71
- data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -67
- data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -80
- 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::
|
|
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
|
-
#
|
|
6
|
+
# Provider-level capability checks and narrow registry fallbacks.
|
|
7
7
|
module Capabilities
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
18
|
-
|
|
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
|
|
25
|
-
|
|
26
|
+
def supports_tool_parallel_control?(_model_id)
|
|
27
|
+
false
|
|
26
28
|
end
|
|
27
29
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
+
def context_window_for(model_id)
|
|
31
|
+
model_id.match?(/sonar-pro/) ? 200_000 : 128_000
|
|
30
32
|
end
|
|
31
33
|
|
|
32
|
-
def
|
|
33
|
-
|
|
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
|
|
40
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
)
|