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.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +200 -41
  3. data/app/assets/stylesheets/raif/admin/stats.scss +12 -0
  4. data/app/controllers/raif/admin/application_controller.rb +14 -0
  5. data/app/controllers/raif/admin/stats/tasks_controller.rb +25 -0
  6. data/app/controllers/raif/admin/stats_controller.rb +19 -0
  7. data/app/controllers/raif/admin/tasks_controller.rb +18 -2
  8. data/app/controllers/raif/conversations_controller.rb +5 -1
  9. data/app/models/raif/agent.rb +11 -9
  10. data/app/models/raif/agents/native_tool_calling_agent.rb +11 -1
  11. data/app/models/raif/agents/re_act_agent.rb +6 -0
  12. data/app/models/raif/concerns/has_available_model_tools.rb +1 -1
  13. data/app/models/raif/concerns/json_schema_definition.rb +28 -0
  14. data/app/models/raif/concerns/llm_response_parsing.rb +23 -1
  15. data/app/models/raif/concerns/llm_temperature.rb +17 -0
  16. data/app/models/raif/concerns/llms/anthropic/message_formatting.rb +51 -0
  17. data/app/models/raif/concerns/llms/bedrock_claude/message_formatting.rb +70 -0
  18. data/app/models/raif/concerns/llms/message_formatting.rb +41 -0
  19. data/app/models/raif/concerns/llms/open_ai/message_formatting.rb +41 -0
  20. data/app/models/raif/conversation.rb +11 -3
  21. data/app/models/raif/conversation_entry.rb +22 -6
  22. data/app/models/raif/embedding_model.rb +22 -0
  23. data/app/models/raif/embedding_models/bedrock_titan.rb +34 -0
  24. data/app/models/raif/embedding_models/open_ai.rb +40 -0
  25. data/app/models/raif/llm.rb +39 -6
  26. data/app/models/raif/llms/anthropic.rb +23 -28
  27. data/app/models/raif/llms/bedrock_claude.rb +33 -19
  28. data/app/models/raif/llms/open_ai.rb +14 -17
  29. data/app/models/raif/llms/open_router.rb +93 -0
  30. data/app/models/raif/model_completion.rb +21 -2
  31. data/app/models/raif/model_file_input.rb +113 -0
  32. data/app/models/raif/model_image_input.rb +4 -0
  33. data/app/models/raif/model_tool.rb +77 -51
  34. data/app/models/raif/model_tool_invocation.rb +8 -6
  35. data/app/models/raif/model_tools/agent_final_answer.rb +18 -27
  36. data/app/models/raif/model_tools/fetch_url.rb +27 -36
  37. data/app/models/raif/model_tools/wikipedia_search.rb +46 -55
  38. data/app/models/raif/task.rb +71 -16
  39. data/app/views/layouts/raif/admin.html.erb +10 -0
  40. data/app/views/raif/admin/agents/show.html.erb +3 -1
  41. data/app/views/raif/admin/conversations/_conversation.html.erb +1 -1
  42. data/app/views/raif/admin/conversations/show.html.erb +3 -1
  43. data/app/views/raif/admin/model_completions/_model_completion.html.erb +1 -0
  44. data/app/views/raif/admin/model_completions/index.html.erb +1 -0
  45. data/app/views/raif/admin/model_completions/show.html.erb +30 -3
  46. data/app/views/raif/admin/stats/index.html.erb +128 -0
  47. data/app/views/raif/admin/stats/tasks/index.html.erb +45 -0
  48. data/app/views/raif/admin/tasks/_task.html.erb +5 -4
  49. data/app/views/raif/admin/tasks/index.html.erb +20 -2
  50. data/app/views/raif/admin/tasks/show.html.erb +3 -1
  51. data/app/views/raif/conversation_entries/_conversation_entry.html.erb +18 -14
  52. data/app/views/raif/conversation_entries/_form.html.erb +1 -1
  53. data/app/views/raif/conversation_entries/_form_with_available_tools.html.erb +4 -4
  54. data/app/views/raif/conversation_entries/_message.html.erb +10 -3
  55. data/config/locales/admin.en.yml +14 -0
  56. data/config/locales/en.yml +25 -3
  57. data/config/routes.rb +6 -0
  58. data/db/migrate/20250421202149_add_response_format_to_raif_conversations.rb +7 -0
  59. data/db/migrate/20250424200755_add_cost_columns_to_raif_model_completions.rb +14 -0
  60. data/db/migrate/20250424232946_add_created_at_indexes.rb +11 -0
  61. data/db/migrate/20250502155330_add_status_indexes_to_raif_tasks.rb +14 -0
  62. data/db/migrate/20250507155314_add_retry_count_to_raif_model_completions.rb +7 -0
  63. data/lib/generators/raif/agent/agent_generator.rb +22 -12
  64. data/lib/generators/raif/agent/templates/agent.rb.tt +3 -3
  65. data/lib/generators/raif/agent/templates/application_agent.rb.tt +7 -0
  66. data/lib/generators/raif/conversation/conversation_generator.rb +10 -0
  67. data/lib/generators/raif/conversation/templates/application_conversation.rb.tt +7 -0
  68. data/lib/generators/raif/conversation/templates/conversation.rb.tt +13 -11
  69. data/lib/generators/raif/install/templates/initializer.rb +50 -6
  70. data/lib/generators/raif/model_tool/model_tool_generator.rb +0 -5
  71. data/lib/generators/raif/model_tool/templates/model_tool.rb.tt +69 -56
  72. data/lib/generators/raif/task/templates/task.rb.tt +34 -23
  73. data/lib/raif/configuration.rb +40 -3
  74. data/lib/raif/embedding_model_registry.rb +83 -0
  75. data/lib/raif/engine.rb +34 -1
  76. data/lib/raif/errors/{open_ai/api_error.rb → invalid_model_file_input_error.rb} +1 -3
  77. data/lib/raif/errors/{anthropic/api_error.rb → invalid_model_image_input_error.rb} +1 -3
  78. data/lib/raif/errors/unsupported_feature_error.rb +8 -0
  79. data/lib/raif/errors.rb +3 -2
  80. data/lib/raif/json_schema_builder.rb +104 -0
  81. data/lib/raif/llm_registry.rb +205 -0
  82. data/lib/raif/version.rb +1 -1
  83. data/lib/raif.rb +5 -32
  84. data/lib/tasks/raif_tasks.rake +9 -4
  85. metadata +32 -19
  86. 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: :text,
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
- self.user_message = <<~MESSAGE.strip
37
- #{raif_user_tool_invocation.as_user_message}
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
- #{user_message}
40
- MESSAGE
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
- tool_klass&.invoke_tool(tool_arguments: tool_call["arguments"], source: self)
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
@@ -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: "user", content: message }] if message.present?
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
- perform_model_completion!(model_completion)
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, "Raif::Llm subclasses must implement #perform_model_completion!"
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 = build_api_parameters(model_completion)
7
-
7
+ params = build_request_parameters(model_completion)
8
8
  response = connection.post("messages") do |req|
9
- req.body = params.to_json
9
+ req.body = params
10
10
  end
11
11
 
12
- resp = JSON.parse(response.body, symbolize_names: true)
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(resp)
15
+ extract_json_response(response_json)
22
16
  else
23
- extract_text_response(resp)
17
+ extract_text_response(response_json)
24
18
  end
25
19
 
26
- model_completion.response_tool_calls = extract_response_tool_calls(resp)
27
- model_completion.completion_tokens = resp&.dig(:usage, :output_tokens)
28
- model_completion.prompt_tokens = resp&.dig(:usage, :input_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 build_api_parameters(model_completion)
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(:content)&.first&.dig(:text)
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(:content).nil?
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
- tool_name = "json_response"
91
- tool_response = resp&.dig(:content)&.find do |content|
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[:input])
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(:content).nil?
98
+ return if resp&.dig("content").nil?
104
99
 
105
100
  # Find any tool_use content blocks
106
- tool_uses = resp&.dig(:content)&.select do |content|
107
- content[:type] == "tool_use"
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[:name],
115
- "arguments" => tool_use[:input]
109
+ "name" => tool_use["name"],
110
+ "arguments" => tool_use["input"]
116
111
  }
117
112
  end
118
113
  end