raif 1.0.0 → 1.1.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 +200 -41
- data/app/assets/stylesheets/raif/admin/stats.scss +12 -0
- data/app/controllers/raif/admin/application_controller.rb +14 -0
- data/app/controllers/raif/admin/stats/tasks_controller.rb +25 -0
- data/app/controllers/raif/admin/stats_controller.rb +19 -0
- data/app/controllers/raif/admin/tasks_controller.rb +18 -2
- data/app/controllers/raif/conversations_controller.rb +5 -1
- data/app/models/raif/agent.rb +11 -9
- data/app/models/raif/agents/native_tool_calling_agent.rb +11 -1
- data/app/models/raif/agents/re_act_agent.rb +6 -0
- data/app/models/raif/concerns/has_available_model_tools.rb +1 -1
- data/app/models/raif/concerns/json_schema_definition.rb +28 -0
- data/app/models/raif/concerns/llm_response_parsing.rb +23 -1
- data/app/models/raif/concerns/llm_temperature.rb +17 -0
- data/app/models/raif/concerns/llms/anthropic/message_formatting.rb +51 -0
- data/app/models/raif/concerns/llms/bedrock_claude/message_formatting.rb +70 -0
- data/app/models/raif/concerns/llms/message_formatting.rb +41 -0
- data/app/models/raif/concerns/llms/open_ai/message_formatting.rb +41 -0
- data/app/models/raif/conversation.rb +11 -3
- data/app/models/raif/conversation_entry.rb +22 -6
- data/app/models/raif/embedding_model.rb +22 -0
- data/app/models/raif/embedding_models/bedrock_titan.rb +34 -0
- data/app/models/raif/embedding_models/open_ai.rb +40 -0
- data/app/models/raif/llm.rb +39 -6
- data/app/models/raif/llms/anthropic.rb +23 -28
- data/app/models/raif/llms/bedrock_claude.rb +33 -19
- data/app/models/raif/llms/open_ai.rb +14 -17
- data/app/models/raif/llms/open_router.rb +93 -0
- data/app/models/raif/model_completion.rb +21 -2
- data/app/models/raif/model_file_input.rb +113 -0
- data/app/models/raif/model_image_input.rb +4 -0
- data/app/models/raif/model_tool.rb +77 -51
- data/app/models/raif/model_tool_invocation.rb +8 -6
- data/app/models/raif/model_tools/agent_final_answer.rb +18 -27
- data/app/models/raif/model_tools/fetch_url.rb +27 -36
- data/app/models/raif/model_tools/wikipedia_search.rb +46 -55
- data/app/models/raif/task.rb +71 -16
- data/app/views/layouts/raif/admin.html.erb +10 -0
- data/app/views/raif/admin/agents/show.html.erb +3 -1
- data/app/views/raif/admin/conversations/_conversation.html.erb +1 -1
- data/app/views/raif/admin/conversations/show.html.erb +3 -1
- data/app/views/raif/admin/model_completions/_model_completion.html.erb +1 -0
- data/app/views/raif/admin/model_completions/index.html.erb +1 -0
- data/app/views/raif/admin/model_completions/show.html.erb +30 -3
- data/app/views/raif/admin/stats/index.html.erb +128 -0
- data/app/views/raif/admin/stats/tasks/index.html.erb +45 -0
- data/app/views/raif/admin/tasks/_task.html.erb +5 -4
- data/app/views/raif/admin/tasks/index.html.erb +20 -2
- data/app/views/raif/admin/tasks/show.html.erb +3 -1
- data/app/views/raif/conversation_entries/_conversation_entry.html.erb +18 -14
- data/app/views/raif/conversation_entries/_form.html.erb +1 -1
- data/app/views/raif/conversation_entries/_form_with_available_tools.html.erb +4 -4
- data/app/views/raif/conversation_entries/_message.html.erb +10 -3
- data/config/locales/admin.en.yml +14 -0
- data/config/locales/en.yml +25 -3
- data/config/routes.rb +6 -0
- data/db/migrate/20250421202149_add_response_format_to_raif_conversations.rb +7 -0
- data/db/migrate/20250424200755_add_cost_columns_to_raif_model_completions.rb +14 -0
- data/db/migrate/20250424232946_add_created_at_indexes.rb +11 -0
- data/db/migrate/20250502155330_add_status_indexes_to_raif_tasks.rb +14 -0
- data/db/migrate/20250507155314_add_retry_count_to_raif_model_completions.rb +7 -0
- data/lib/generators/raif/agent/agent_generator.rb +22 -12
- data/lib/generators/raif/agent/templates/agent.rb.tt +3 -3
- data/lib/generators/raif/agent/templates/application_agent.rb.tt +7 -0
- data/lib/generators/raif/conversation/conversation_generator.rb +10 -0
- data/lib/generators/raif/conversation/templates/application_conversation.rb.tt +7 -0
- data/lib/generators/raif/conversation/templates/conversation.rb.tt +13 -11
- data/lib/generators/raif/install/templates/initializer.rb +50 -6
- data/lib/generators/raif/model_tool/model_tool_generator.rb +0 -5
- data/lib/generators/raif/model_tool/templates/model_tool.rb.tt +69 -56
- data/lib/generators/raif/task/templates/task.rb.tt +34 -23
- data/lib/raif/configuration.rb +40 -3
- data/lib/raif/embedding_model_registry.rb +83 -0
- data/lib/raif/engine.rb +34 -1
- data/lib/raif/errors/{open_ai/api_error.rb → invalid_model_file_input_error.rb} +1 -3
- data/lib/raif/errors/{anthropic/api_error.rb → invalid_model_image_input_error.rb} +1 -3
- data/lib/raif/errors/unsupported_feature_error.rb +8 -0
- data/lib/raif/errors.rb +3 -2
- data/lib/raif/json_schema_builder.rb +104 -0
- data/lib/raif/llm_registry.rb +205 -0
- data/lib/raif/version.rb +1 -1
- data/lib/raif.rb +5 -32
- data/lib/tasks/raif_tasks.rake +9 -4
- metadata +32 -19
- data/lib/raif/default_llms.rb +0 -37
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Raif::Concerns::Llms::Anthropic::MessageFormatting
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
def format_model_image_input_message(image_input)
|
7
|
+
if image_input.source_type == :url
|
8
|
+
{
|
9
|
+
"type" => "image",
|
10
|
+
"source" => {
|
11
|
+
"type" => "url",
|
12
|
+
"url" => image_input.url
|
13
|
+
}
|
14
|
+
}
|
15
|
+
elsif image_input.source_type == :file_content
|
16
|
+
{
|
17
|
+
"type" => "image",
|
18
|
+
"source" => {
|
19
|
+
"type" => "base64",
|
20
|
+
"media_type" => image_input.content_type,
|
21
|
+
"data" => image_input.base64_data
|
22
|
+
}
|
23
|
+
}
|
24
|
+
else
|
25
|
+
raise Raif::Errors::InvalidModelImageInputError, "Invalid model image input source type: #{image_input.source_type}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def format_model_file_input_message(file_input)
|
30
|
+
if file_input.source_type == :url
|
31
|
+
{
|
32
|
+
"type" => "document",
|
33
|
+
"source" => {
|
34
|
+
"type" => "url",
|
35
|
+
"url" => file_input.url
|
36
|
+
}
|
37
|
+
}
|
38
|
+
elsif file_input.source_type == :file_content
|
39
|
+
{
|
40
|
+
"type" => "document",
|
41
|
+
"source" => {
|
42
|
+
"type" => "base64",
|
43
|
+
"media_type" => file_input.content_type,
|
44
|
+
"data" => file_input.base64_data
|
45
|
+
}
|
46
|
+
}
|
47
|
+
else
|
48
|
+
raise Raif::Errors::InvalidModelFileInputError, "Invalid model file input source type: #{file_input.source_type}"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Raif::Concerns::Llms::BedrockClaude::MessageFormatting
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
def format_string_message(content)
|
7
|
+
{ "text" => content }
|
8
|
+
end
|
9
|
+
|
10
|
+
def format_model_image_input_message(image_input)
|
11
|
+
if image_input.source_type == :url
|
12
|
+
raise Raif::Errors::UnsupportedFeatureError, "AWS Bedrock does not support providing an image by URL"
|
13
|
+
elsif image_input.source_type == :file_content
|
14
|
+
# The AWS Bedrock SDK requires data sent as bytes (and doesn't support base64 like everyone else)
|
15
|
+
# The ModelCompletion stores the messages as JSON though, so it can't be raw bytes (it will throw an encoding error).
|
16
|
+
# We store the image data as base64 and then it will get converted to bytes in Raif::Llms::BedrockClaude#perform_model_completion!
|
17
|
+
# before sending to AWS.
|
18
|
+
{
|
19
|
+
"image" => {
|
20
|
+
"format" => format_for_content_type(image_input.content_type),
|
21
|
+
"source" => {
|
22
|
+
"tmp_base64_data" => image_input.base64_data
|
23
|
+
}
|
24
|
+
}
|
25
|
+
}
|
26
|
+
else
|
27
|
+
raise Raif::Errors::InvalidModelImageInputError, "Invalid model image input source type: #{image_input.source_type}"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def format_model_file_input_message(file_input)
|
32
|
+
if file_input.source_type == :url
|
33
|
+
raise Raif::Errors::UnsupportedFeatureError, "AWS Bedrock does not support providing a file by URL"
|
34
|
+
elsif file_input.source_type == :file_content
|
35
|
+
# The AWS Bedrock SDK requires data sent as bytes (and doesn't support base64 like everyone else)
|
36
|
+
# The ModelCompletion stores the messages as JSON though, so it can't be raw bytes (it will throw an encoding error).
|
37
|
+
# We store the image data as base64 and then it will get converted to bytes in Raif::Llms::BedrockClaude#perform_model_completion!
|
38
|
+
# before sending to AWS.
|
39
|
+
{
|
40
|
+
"document" => {
|
41
|
+
"format" => format_for_content_type(file_input.content_type),
|
42
|
+
"name" => File.basename(file_input.filename, File.extname(file_input.filename)), # AWS requires a filename and it cannot include dots from the extension # rubocop:disable Layout/LineLength
|
43
|
+
"source" => {
|
44
|
+
"tmp_base64_data" => file_input.base64_data
|
45
|
+
}
|
46
|
+
}
|
47
|
+
}
|
48
|
+
else
|
49
|
+
raise Raif::Errors::InvalidModelFileInputError, "Invalid model file input source type: #{file_input.source_type}"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def format_for_content_type(content_type)
|
54
|
+
{
|
55
|
+
"image/png" => "png",
|
56
|
+
"image/jpeg" => "jpeg",
|
57
|
+
"image/gif" => "gif",
|
58
|
+
"image/webp" => "webp",
|
59
|
+
"application/pdf" => "pdf",
|
60
|
+
"text/csv" => "csv",
|
61
|
+
"application/msword" => "doc",
|
62
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
|
63
|
+
"application/vnd.ms-excel" => "xls",
|
64
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx",
|
65
|
+
"text/html" => "html",
|
66
|
+
"text/plain" => "txt",
|
67
|
+
"text/markdown" => "md"
|
68
|
+
}[content_type]
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Raif::Concerns::Llms::MessageFormatting
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
def format_messages(messages)
|
7
|
+
messages.map do |message|
|
8
|
+
{
|
9
|
+
"role" => message["role"] || message[:role],
|
10
|
+
"content" => format_message_content(message["content"] || message[:content])
|
11
|
+
}
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# Content could be a string or an array.
|
16
|
+
# If it's an array, it could contain Raif::ModelImageInput or Raif::ModelFileInput objects,
|
17
|
+
# which need to be formatted according to each model provider's API.
|
18
|
+
def format_message_content(content)
|
19
|
+
raise ArgumentError,
|
20
|
+
"Message content must be an array or a string. Content was: #{content.inspect}" unless content.is_a?(Array) || content.is_a?(String)
|
21
|
+
|
22
|
+
return [format_string_message(content)] if content.is_a?(String)
|
23
|
+
|
24
|
+
content.map do |item|
|
25
|
+
if item.is_a?(Raif::ModelImageInput)
|
26
|
+
format_model_image_input_message(item)
|
27
|
+
elsif item.is_a?(Raif::ModelFileInput)
|
28
|
+
format_model_file_input_message(item)
|
29
|
+
elsif item.is_a?(String)
|
30
|
+
format_string_message(item)
|
31
|
+
else
|
32
|
+
item
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def format_string_message(content)
|
38
|
+
{ "type" => "text", "text" => content }
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Raif::Concerns::Llms::OpenAi::MessageFormatting
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
def format_model_image_input_message(image_input)
|
7
|
+
if image_input.source_type == :url
|
8
|
+
{
|
9
|
+
"type" => "image_url",
|
10
|
+
"image_url" => { "url" => image_input.url }
|
11
|
+
}
|
12
|
+
elsif image_input.source_type == :file_content
|
13
|
+
{
|
14
|
+
"type" => "image_url",
|
15
|
+
"image_url" => {
|
16
|
+
"url" => "data:#{image_input.content_type};base64,#{image_input.base64_data}"
|
17
|
+
}
|
18
|
+
}
|
19
|
+
else
|
20
|
+
raise Raif::Errors::InvalidModelImageInputError, "Invalid model image input source type: #{image_input.source_type}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def format_model_file_input_message(file_input)
|
25
|
+
if file_input.source_type == :url
|
26
|
+
raise Raif::Errors::UnsupportedFeatureError, "#{self.class.name} does not support providing a file by URL"
|
27
|
+
elsif file_input.source_type == :file_content
|
28
|
+
file_params = {
|
29
|
+
"filename" => file_input.filename,
|
30
|
+
"file_data" => "data:#{file_input.content_type};base64,#{file_input.base64_data}"
|
31
|
+
}.compact
|
32
|
+
|
33
|
+
{
|
34
|
+
"type" => "file",
|
35
|
+
"file" => file_params
|
36
|
+
}
|
37
|
+
else
|
38
|
+
raise Raif::Errors::InvalidModelFileInputError, "Invalid model image input source type: #{file_input.source_type}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -4,6 +4,7 @@ class Raif::Conversation < Raif::ApplicationRecord
|
|
4
4
|
include Raif::Concerns::HasLlm
|
5
5
|
include Raif::Concerns::HasRequestedLanguage
|
6
6
|
include Raif::Concerns::HasAvailableModelTools
|
7
|
+
include Raif::Concerns::LlmResponseParsing
|
7
8
|
|
8
9
|
belongs_to :creator, polymorphic: true
|
9
10
|
|
@@ -25,7 +26,8 @@ class Raif::Conversation < Raif::ApplicationRecord
|
|
25
26
|
end
|
26
27
|
|
27
28
|
def system_prompt_intro
|
28
|
-
Raif.config.conversation_system_prompt_intro
|
29
|
+
sp = Raif.config.conversation_system_prompt_intro
|
30
|
+
sp.respond_to?(:call) ? sp.call(self) : sp
|
29
31
|
end
|
30
32
|
|
31
33
|
# i18n-tasks-use t('raif.conversation.initial_chat_message')
|
@@ -37,17 +39,23 @@ class Raif::Conversation < Raif::ApplicationRecord
|
|
37
39
|
llm.chat(
|
38
40
|
messages: llm_messages,
|
39
41
|
source: entry,
|
40
|
-
response_format:
|
42
|
+
response_format: response_format.to_sym,
|
41
43
|
system_prompt: system_prompt,
|
42
44
|
available_model_tools: available_model_tools
|
43
45
|
)
|
44
46
|
end
|
45
47
|
|
48
|
+
def process_model_response_message(message:, entry:)
|
49
|
+
# no-op by default.
|
50
|
+
# Override in subclasses for type-specific processing of the model response message
|
51
|
+
message
|
52
|
+
end
|
53
|
+
|
46
54
|
def llm_messages
|
47
55
|
messages = []
|
48
56
|
|
49
57
|
entries.oldest_first.includes(:raif_model_tool_invocations).each do |entry|
|
50
|
-
messages << { "role" => "user", "content" => entry.user_message }
|
58
|
+
messages << { "role" => "user", "content" => entry.user_message } unless entry.user_message.blank?
|
51
59
|
next unless entry.completed?
|
52
60
|
|
53
61
|
messages << { "role" => "assistant", "content" => entry.model_response_message } unless entry.model_response_message.blank?
|
@@ -33,11 +33,12 @@ class Raif::ConversationEntry < Raif::ApplicationRecord
|
|
33
33
|
def add_user_tool_invocation_to_user_message
|
34
34
|
return unless raif_user_tool_invocation.present?
|
35
35
|
|
36
|
-
|
37
|
-
|
36
|
+
separator = response_format == "html" ? "<br>" : "\n\n"
|
37
|
+
self.user_message = [user_message, raif_user_tool_invocation.as_user_message].join(separator)
|
38
|
+
end
|
38
39
|
|
39
|
-
|
40
|
-
|
40
|
+
def response_format
|
41
|
+
raif_model_completion&.response_format.presence || raif_conversation.response_format
|
41
42
|
end
|
42
43
|
|
43
44
|
def generating_response?
|
@@ -49,6 +50,7 @@ class Raif::ConversationEntry < Raif::ApplicationRecord
|
|
49
50
|
|
50
51
|
if raif_model_completion.parsed_response.present? || raif_model_completion.response_tool_calls.present?
|
51
52
|
extract_message_and_invoke_tools!
|
53
|
+
create_entry_for_observation! if triggers_observation_to_model?
|
52
54
|
else
|
53
55
|
logger.error "Error processing conversation entry ##{id}. No model response found."
|
54
56
|
failed!
|
@@ -57,18 +59,32 @@ class Raif::ConversationEntry < Raif::ApplicationRecord
|
|
57
59
|
self
|
58
60
|
end
|
59
61
|
|
62
|
+
def triggers_observation_to_model?
|
63
|
+
return false unless completed?
|
64
|
+
|
65
|
+
raif_model_tool_invocations.any?(&:triggers_observation_to_model?)
|
66
|
+
end
|
67
|
+
|
68
|
+
def create_entry_for_observation!
|
69
|
+
follow_up_entry = raif_conversation.entries.create!(creator: creator)
|
70
|
+
Raif::ConversationEntryJob.perform_later(conversation_entry: follow_up_entry)
|
71
|
+
follow_up_entry.broadcast_append_to raif_conversation, target: dom_id(raif_conversation, :entries)
|
72
|
+
end
|
73
|
+
|
60
74
|
private
|
61
75
|
|
62
76
|
def extract_message_and_invoke_tools!
|
63
77
|
transaction do
|
64
78
|
self.raw_response = raif_model_completion.raw_response
|
65
|
-
self.model_response_message = raif_model_completion.parsed_response
|
79
|
+
self.model_response_message = raif_conversation.process_model_response_message(message: raif_model_completion.parsed_response, entry: self)
|
66
80
|
save!
|
67
81
|
|
68
82
|
if raif_model_completion.response_tool_calls.present?
|
69
83
|
raif_model_completion.response_tool_calls.each do |tool_call|
|
70
84
|
tool_klass = available_model_tools_map[tool_call["name"]]
|
71
|
-
|
85
|
+
next if tool_klass.nil?
|
86
|
+
|
87
|
+
tool_klass.invoke_tool(tool_arguments: tool_call["arguments"], source: self)
|
72
88
|
end
|
73
89
|
end
|
74
90
|
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Raif::EmbeddingModel
|
4
|
+
include ActiveModel::Model
|
5
|
+
|
6
|
+
attr_accessor :key,
|
7
|
+
:api_name,
|
8
|
+
:input_token_cost,
|
9
|
+
:default_output_vector_size
|
10
|
+
|
11
|
+
validates :default_output_vector_size, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
12
|
+
validates :api_name, presence: true
|
13
|
+
validates :key, presence: true
|
14
|
+
|
15
|
+
def name
|
16
|
+
I18n.t("raif.embedding_model_names.#{key}")
|
17
|
+
end
|
18
|
+
|
19
|
+
def generate_embedding!(input, dimensions: nil)
|
20
|
+
raise NotImplementedError, "#{self.class.name} must implement #generate_embedding!"
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Raif::EmbeddingModels::BedrockTitan < Raif::EmbeddingModel
|
4
|
+
|
5
|
+
def generate_embedding!(input, dimensions: nil)
|
6
|
+
unless input.is_a?(String)
|
7
|
+
raise ArgumentError, "Raif::EmbeddingModels::BedrockTitan#generate_embedding! input must be a string"
|
8
|
+
end
|
9
|
+
|
10
|
+
params = build_request_parameters(input, dimensions:)
|
11
|
+
response = bedrock_client.invoke_model(params)
|
12
|
+
|
13
|
+
response_body = JSON.parse(response.body.read)
|
14
|
+
response_body["embedding"]
|
15
|
+
rescue Aws::BedrockRuntime::Errors::ServiceError => e
|
16
|
+
raise "Bedrock API error: #{e.message}"
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def build_request_parameters(input, dimensions: nil)
|
22
|
+
body_params = { inputText: input }
|
23
|
+
body_params[:dimensions] = dimensions if dimensions.present?
|
24
|
+
|
25
|
+
{
|
26
|
+
model_id: api_name,
|
27
|
+
body: body_params.to_json
|
28
|
+
}
|
29
|
+
end
|
30
|
+
|
31
|
+
def bedrock_client
|
32
|
+
@bedrock_client ||= Aws::BedrockRuntime::Client.new(region: Raif.config.aws_bedrock_region)
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Raif::EmbeddingModels::OpenAi < Raif::EmbeddingModel
|
4
|
+
def generate_embedding!(input, dimensions: nil)
|
5
|
+
unless input.is_a?(String) || input.is_a?(Array)
|
6
|
+
raise ArgumentError, "Raif::EmbeddingModels::OpenAi#generate_embedding! input must be a string or an array of strings"
|
7
|
+
end
|
8
|
+
|
9
|
+
response = connection.post("embeddings") do |req|
|
10
|
+
req.body = build_request_parameters(input, dimensions:)
|
11
|
+
end
|
12
|
+
|
13
|
+
if input.is_a?(String)
|
14
|
+
response.body["data"][0]["embedding"]
|
15
|
+
else
|
16
|
+
response.body["data"].map{|v| v["embedding"] }
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def build_request_parameters(input, dimensions: nil)
|
23
|
+
params = {
|
24
|
+
model: api_name,
|
25
|
+
input: input
|
26
|
+
}
|
27
|
+
|
28
|
+
params[:dimensions] = dimensions if dimensions.present?
|
29
|
+
params
|
30
|
+
end
|
31
|
+
|
32
|
+
def connection
|
33
|
+
@connection ||= Faraday.new(url: "https://api.openai.com/v1") do |f|
|
34
|
+
f.headers["Authorization"] = "Bearer #{Raif.config.open_ai_api_key}"
|
35
|
+
f.request :json
|
36
|
+
f.response :json
|
37
|
+
f.response :raise_error
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/app/models/raif/llm.rb
CHANGED
@@ -3,13 +3,16 @@
|
|
3
3
|
module Raif
|
4
4
|
class Llm
|
5
5
|
include ActiveModel::Model
|
6
|
+
include Raif::Concerns::Llms::MessageFormatting
|
6
7
|
|
7
8
|
attr_accessor :key,
|
8
9
|
:api_name,
|
9
10
|
:default_temperature,
|
10
11
|
:default_max_completion_tokens,
|
11
12
|
:supports_native_tool_use,
|
12
|
-
:provider_settings
|
13
|
+
:provider_settings,
|
14
|
+
:input_token_cost,
|
15
|
+
:output_token_cost
|
13
16
|
|
14
17
|
validates :key, presence: true
|
15
18
|
validates :api_name, presence: true
|
@@ -18,13 +21,16 @@ module Raif
|
|
18
21
|
|
19
22
|
alias_method :supports_native_tool_use?, :supports_native_tool_use
|
20
23
|
|
21
|
-
def initialize(key:, api_name:, model_provider_settings: {}, supports_native_tool_use: true, temperature: nil, max_completion_tokens: nil
|
24
|
+
def initialize(key:, api_name:, model_provider_settings: {}, supports_native_tool_use: true, temperature: nil, max_completion_tokens: nil,
|
25
|
+
input_token_cost: nil, output_token_cost: nil)
|
22
26
|
@key = key
|
23
27
|
@api_name = api_name
|
24
28
|
@provider_settings = model_provider_settings
|
25
29
|
@supports_native_tool_use = supports_native_tool_use
|
26
30
|
@default_temperature = temperature || 0.7
|
27
31
|
@default_max_completion_tokens = max_completion_tokens
|
32
|
+
@input_token_cost = input_token_cost
|
33
|
+
@output_token_cost = output_token_cost
|
28
34
|
end
|
29
35
|
|
30
36
|
def name
|
@@ -55,13 +61,13 @@ module Raif
|
|
55
61
|
return
|
56
62
|
end
|
57
63
|
|
58
|
-
messages = [{ role
|
64
|
+
messages = [{ "role" => "user", "content" => message }] if message.present?
|
59
65
|
|
60
66
|
temperature ||= default_temperature
|
61
67
|
max_completion_tokens ||= default_max_completion_tokens
|
62
68
|
|
63
69
|
model_completion = Raif::ModelCompletion.new(
|
64
|
-
messages: messages,
|
70
|
+
messages: format_messages(messages),
|
65
71
|
system_prompt: system_prompt,
|
66
72
|
response_format: response_format,
|
67
73
|
source: source,
|
@@ -72,17 +78,44 @@ module Raif
|
|
72
78
|
available_model_tools: available_model_tools
|
73
79
|
)
|
74
80
|
|
75
|
-
|
81
|
+
retry_with_backoff(model_completion) do
|
82
|
+
perform_model_completion!(model_completion)
|
83
|
+
end
|
84
|
+
|
76
85
|
model_completion
|
77
86
|
end
|
78
87
|
|
79
88
|
def perform_model_completion!(model_completion)
|
80
|
-
raise NotImplementedError, "
|
89
|
+
raise NotImplementedError, "#{self.class.name} must implement #perform_model_completion!"
|
81
90
|
end
|
82
91
|
|
83
92
|
def self.valid_response_formats
|
84
93
|
VALID_RESPONSE_FORMATS
|
85
94
|
end
|
86
95
|
|
96
|
+
private
|
97
|
+
|
98
|
+
def retry_with_backoff(model_completion)
|
99
|
+
retries = 0
|
100
|
+
max_retries = Raif.config.llm_request_max_retries
|
101
|
+
base_delay = 3
|
102
|
+
max_delay = 30
|
103
|
+
|
104
|
+
begin
|
105
|
+
yield
|
106
|
+
rescue *Raif.config.llm_request_retriable_exceptions => e
|
107
|
+
retries += 1
|
108
|
+
if retries <= max_retries
|
109
|
+
delay = [base_delay * (2**(retries - 1)), max_delay].min
|
110
|
+
Raif.logger.warn("Retrying LLM API request after error: #{e.message}. Attempt #{retries}/#{max_retries}. Waiting #{delay} seconds...")
|
111
|
+
model_completion.increment!(:retry_count)
|
112
|
+
sleep delay
|
113
|
+
retry
|
114
|
+
else
|
115
|
+
Raif.logger.error("LLM API request failed after #{max_retries} retries. Last error: #{e.message}")
|
116
|
+
raise
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
87
120
|
end
|
88
121
|
end
|
@@ -1,31 +1,25 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
class Raif::Llms::Anthropic < Raif::Llm
|
4
|
+
include Raif::Concerns::Llms::Anthropic::MessageFormatting
|
4
5
|
|
5
6
|
def perform_model_completion!(model_completion)
|
6
|
-
params =
|
7
|
-
|
7
|
+
params = build_request_parameters(model_completion)
|
8
8
|
response = connection.post("messages") do |req|
|
9
|
-
req.body = params
|
9
|
+
req.body = params
|
10
10
|
end
|
11
11
|
|
12
|
-
|
13
|
-
|
14
|
-
# Handle API errors
|
15
|
-
unless response.success?
|
16
|
-
error_message = resp[:error]&.dig(:message) || "Anthropic API error: #{response.status}"
|
17
|
-
raise Raif::Errors::Anthropic::ApiError, error_message
|
18
|
-
end
|
12
|
+
response_json = response.body
|
19
13
|
|
20
14
|
model_completion.raw_response = if model_completion.response_format_json?
|
21
|
-
extract_json_response(
|
15
|
+
extract_json_response(response_json)
|
22
16
|
else
|
23
|
-
extract_text_response(
|
17
|
+
extract_text_response(response_json)
|
24
18
|
end
|
25
19
|
|
26
|
-
model_completion.response_tool_calls = extract_response_tool_calls(
|
27
|
-
model_completion.completion_tokens =
|
28
|
-
model_completion.prompt_tokens =
|
20
|
+
model_completion.response_tool_calls = extract_response_tool_calls(response_json)
|
21
|
+
model_completion.completion_tokens = response_json&.dig("usage", "output_tokens")
|
22
|
+
model_completion.prompt_tokens = response_json&.dig("usage", "input_tokens")
|
29
23
|
model_completion.save!
|
30
24
|
|
31
25
|
model_completion
|
@@ -33,15 +27,17 @@ class Raif::Llms::Anthropic < Raif::Llm
|
|
33
27
|
|
34
28
|
def connection
|
35
29
|
@connection ||= Faraday.new(url: "https://api.anthropic.com/v1") do |f|
|
36
|
-
f.headers["Content-Type"] = "application/json"
|
37
30
|
f.headers["x-api-key"] = Raif.config.anthropic_api_key
|
38
31
|
f.headers["anthropic-version"] = "2023-06-01"
|
32
|
+
f.request :json
|
33
|
+
f.response :json
|
34
|
+
f.response :raise_error
|
39
35
|
end
|
40
36
|
end
|
41
37
|
|
42
38
|
protected
|
43
39
|
|
44
|
-
def
|
40
|
+
def build_request_parameters(model_completion)
|
45
41
|
params = {
|
46
42
|
model: model_completion.model_api_name,
|
47
43
|
messages: model_completion.messages,
|
@@ -80,39 +76,38 @@ protected
|
|
80
76
|
end
|
81
77
|
|
82
78
|
def extract_text_response(resp)
|
83
|
-
resp&.dig(
|
79
|
+
resp&.dig("content")&.first&.dig("text")
|
84
80
|
end
|
85
81
|
|
86
82
|
def extract_json_response(resp)
|
87
|
-
return extract_text_response(resp) if resp&.dig(
|
83
|
+
return extract_text_response(resp) if resp&.dig("content").nil?
|
88
84
|
|
89
85
|
# Look for tool_use blocks in the content array
|
90
|
-
|
91
|
-
|
92
|
-
content[:type] == "tool_use" && content[:name] == tool_name
|
86
|
+
tool_response = resp&.dig("content")&.find do |content|
|
87
|
+
content["type"] == "tool_use" && content["name"] == "json_response"
|
93
88
|
end
|
94
89
|
|
95
90
|
if tool_response
|
96
|
-
JSON.generate(tool_response[
|
91
|
+
JSON.generate(tool_response["input"])
|
97
92
|
else
|
98
93
|
extract_text_response(resp)
|
99
94
|
end
|
100
95
|
end
|
101
96
|
|
102
97
|
def extract_response_tool_calls(resp)
|
103
|
-
return if resp&.dig(
|
98
|
+
return if resp&.dig("content").nil?
|
104
99
|
|
105
100
|
# Find any tool_use content blocks
|
106
|
-
tool_uses = resp&.dig(
|
107
|
-
content[
|
101
|
+
tool_uses = resp&.dig("content")&.select do |content|
|
102
|
+
content["type"] == "tool_use"
|
108
103
|
end
|
109
104
|
|
110
105
|
return if tool_uses.blank?
|
111
106
|
|
112
107
|
tool_uses.map do |tool_use|
|
113
108
|
{
|
114
|
-
"name" => tool_use[
|
115
|
-
"arguments" => tool_use[
|
109
|
+
"name" => tool_use["name"],
|
110
|
+
"arguments" => tool_use["input"]
|
116
111
|
}
|
117
112
|
end
|
118
113
|
end
|