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.
- checksums.yaml +4 -4
- data/README.md +16 -13
- 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 +1 -1
- 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 +41 -17
- data/lib/generators/ruby_llm/install/install_generator.rb +21 -18
- data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +3 -4
- data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +1 -1
- data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +3 -3
- 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/upgrade_to_v1_10_generator.rb +1 -1
- 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/upgrade_to_v1_7_generator.rb +5 -7
- 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 +6 -26
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +125 -29
- data/lib/ruby_llm/active_record/chat_methods.rb +50 -26
- data/lib/ruby_llm/active_record/message_methods.rb +104 -4
- data/lib/ruby_llm/active_record/model_methods.rb +8 -10
- data/lib/ruby_llm/active_record/payload_helpers.rb +29 -0
- data/lib/ruby_llm/active_record/tool_call_methods.rb +18 -0
- data/lib/ruby_llm/agent.rb +15 -2
- data/lib/ruby_llm/aliases.json +141 -89
- data/lib/ruby_llm/aliases.rb +3 -0
- data/lib/ruby_llm/attachment.rb +44 -40
- data/lib/ruby_llm/chat.rb +229 -59
- data/lib/ruby_llm/configuration.rb +69 -75
- data/lib/ruby_llm/connection.rb +37 -10
- data/lib/ruby_llm/content.rb +15 -1
- data/lib/ruby_llm/cost.rb +224 -0
- data/lib/ruby_llm/deprecator.rb +24 -0
- data/lib/ruby_llm/embedding.rb +31 -1
- data/lib/ruby_llm/error.rb +16 -75
- data/lib/ruby_llm/error_middleware.rb +81 -0
- data/lib/ruby_llm/image.rb +39 -4
- data/lib/ruby_llm/instrumentation.rb +36 -0
- data/lib/ruby_llm/message.rb +20 -0
- data/lib/ruby_llm/mime_type.rb +25 -0
- data/lib/ruby_llm/model/info.rb +67 -14
- data/lib/ruby_llm/model/pricing.rb +19 -9
- data/lib/ruby_llm/model/pricing_category.rb +13 -2
- data/lib/ruby_llm/model/pricing_tier.rb +20 -9
- data/lib/ruby_llm/model_registry.rb +39 -0
- data/lib/ruby_llm/models.json +21796 -20601
- data/lib/ruby_llm/models.rb +107 -34
- data/lib/ruby_llm/models_schema.json +3 -0
- data/lib/ruby_llm/provider.rb +25 -4
- data/lib/ruby_llm/providers/anthropic/capabilities.rb +1 -133
- data/lib/ruby_llm/providers/anthropic/chat.rb +49 -15
- data/lib/ruby_llm/providers/anthropic/models.rb +4 -8
- data/lib/ruby_llm/providers/anthropic/streaming.rb +2 -0
- data/lib/ruby_llm/providers/anthropic/tools.rb +32 -3
- data/lib/ruby_llm/providers/anthropic.rb +4 -0
- data/lib/ruby_llm/providers/azure/media.rb +1 -1
- data/lib/ruby_llm/providers/azure.rb +4 -0
- data/lib/ruby_llm/providers/bedrock/auth.rb +1 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +26 -13
- data/lib/ruby_llm/providers/bedrock/media.rb +21 -3
- data/lib/ruby_llm/providers/bedrock/models.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/streaming.rb +10 -1
- data/lib/ruby_llm/providers/bedrock.rb +5 -1
- data/lib/ruby_llm/providers/deepseek/capabilities.rb +28 -103
- data/lib/ruby_llm/providers/deepseek/chat.rb +9 -0
- data/lib/ruby_llm/providers/deepseek.rb +4 -0
- data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -215
- data/lib/ruby_llm/providers/gemini/chat.rb +10 -4
- data/lib/ruby_llm/providers/gemini/images.rb +2 -2
- data/lib/ruby_llm/providers/gemini/media.rb +16 -9
- data/lib/ruby_llm/providers/gemini/models.rb +2 -4
- data/lib/ruby_llm/providers/gemini/streaming.rb +6 -1
- data/lib/ruby_llm/providers/gemini/tools.rb +5 -1
- data/lib/ruby_llm/providers/gemini.rb +4 -0
- data/lib/ruby_llm/providers/gpustack/chat.rb +8 -1
- data/lib/ruby_llm/providers/gpustack/models.rb +2 -0
- data/lib/ruby_llm/providers/gpustack.rb +4 -0
- data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -2
- data/lib/ruby_llm/providers/mistral/chat.rb +56 -5
- data/lib/ruby_llm/providers/mistral/media.rb +55 -0
- data/lib/ruby_llm/providers/mistral/models.rb +2 -0
- data/lib/ruby_llm/providers/mistral.rb +5 -1
- data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
- data/lib/ruby_llm/providers/ollama.rb +4 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +157 -195
- data/lib/ruby_llm/providers/openai/chat.rb +61 -7
- data/lib/ruby_llm/providers/openai/images.rb +58 -6
- data/lib/ruby_llm/providers/openai/media.rb +40 -16
- data/lib/ruby_llm/providers/openai/models.rb +2 -4
- data/lib/ruby_llm/providers/openai/streaming.rb +7 -6
- data/lib/ruby_llm/providers/openai/tools.rb +2 -0
- data/lib/ruby_llm/providers/openai/transcription.rb +1 -0
- data/lib/ruby_llm/providers/openai.rb +10 -0
- data/lib/ruby_llm/providers/openrouter/chat.rb +36 -8
- data/lib/ruby_llm/providers/openrouter/images.rb +3 -3
- data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
- data/lib/ruby_llm/providers/openrouter/streaming.rb +5 -6
- data/lib/ruby_llm/providers/openrouter.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
- data/lib/ruby_llm/providers/perplexity/chat.rb +11 -0
- data/lib/ruby_llm/providers/perplexity/media.rb +62 -0
- data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
- data/lib/ruby_llm/providers/perplexity.rb +5 -1
- data/lib/ruby_llm/providers/vertexai.rb +8 -0
- data/lib/ruby_llm/providers/xai/chat.rb +9 -0
- data/lib/ruby_llm/providers/xai/models.rb +15 -27
- data/lib/ruby_llm/providers/xai.rb +5 -1
- data/lib/ruby_llm/railtie.rb +11 -1
- data/lib/ruby_llm/stream_accumulator.rb +45 -30
- data/lib/ruby_llm/streaming.rb +4 -0
- data/lib/ruby_llm/tokens.rb +8 -0
- data/lib/ruby_llm/tool.rb +24 -7
- data/lib/ruby_llm/tool_concurrency.rb +105 -0
- data/lib/ruby_llm/transcription.rb +2 -1
- data/lib/ruby_llm/utils.rb +39 -0
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm.rb +11 -6
- data/lib/tasks/models.rake +45 -16
- data/lib/tasks/release.rake +51 -24
- data/lib/tasks/ruby_llm.rake +6 -5
- data/lib/tasks/vcr.rake +1 -1
- metadata +81 -21
- 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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
61
|
+
def format_document(document)
|
|
49
62
|
{
|
|
50
63
|
type: 'file',
|
|
51
64
|
file: {
|
|
52
|
-
filename:
|
|
53
|
-
file_data:
|
|
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:
|
|
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
|
-
|
|
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
|
|
31
|
-
output_tokens: usage
|
|
32
|
-
cached_tokens:
|
|
33
|
-
cache_creation_tokens:
|
|
34
|
-
thinking_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
|
|
|
@@ -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
|
-
|
|
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
|
|
74
|
-
output_tokens: usage
|
|
75
|
-
cached_tokens:
|
|
76
|
-
cache_creation_tokens:
|
|
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:
|
|
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: :
|
|
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
|
|
29
|
-
output_tokens: usage
|
|
30
|
-
cached_tokens:
|
|
31
|
-
cache_creation_tokens:
|
|
32
|
-
thinking_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
|
|
|
@@ -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
|
|
@@ -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
|