raif 1.3.0 → 1.4.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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -5
  3. data/app/assets/builds/raif.css +4 -1
  4. data/app/assets/builds/raif_admin.css +13 -1
  5. data/app/assets/javascript/raif/controllers/conversations_controller.js +1 -1
  6. data/app/assets/stylesheets/raif/admin/conversation.scss +16 -0
  7. data/app/assets/stylesheets/raif/conversations.scss +3 -0
  8. data/app/assets/stylesheets/raif.scss +2 -1
  9. data/app/controllers/raif/admin/application_controller.rb +16 -0
  10. data/app/controllers/raif/admin/configs_controller.rb +94 -0
  11. data/app/controllers/raif/admin/model_completions_controller.rb +18 -1
  12. data/app/controllers/raif/admin/model_tool_invocations_controller.rb +7 -1
  13. data/app/controllers/raif/admin/stats/model_tool_invocations_controller.rb +21 -0
  14. data/app/controllers/raif/admin/stats/tasks_controller.rb +15 -6
  15. data/app/controllers/raif/admin/stats_controller.rb +32 -3
  16. data/app/controllers/raif/conversation_entries_controller.rb +1 -0
  17. data/app/controllers/raif/conversations_controller.rb +10 -2
  18. data/app/jobs/raif/conversation_entry_job.rb +8 -6
  19. data/app/models/raif/admin/task_stat.rb +7 -0
  20. data/app/models/raif/agent.rb +63 -2
  21. data/app/models/raif/agents/native_tool_calling_agent.rb +101 -56
  22. data/app/models/raif/application_record.rb +18 -0
  23. data/app/models/raif/concerns/agent_inference_stats.rb +35 -0
  24. data/app/models/raif/concerns/json_schema_definition.rb +40 -5
  25. data/app/models/raif/concerns/llms/anthropic/message_formatting.rb +28 -0
  26. data/app/models/raif/concerns/llms/anthropic/response_tool_calls.rb +24 -0
  27. data/app/models/raif/concerns/llms/anthropic/tool_formatting.rb +4 -0
  28. data/app/models/raif/concerns/llms/bedrock/message_formatting.rb +36 -0
  29. data/app/models/raif/concerns/llms/bedrock/response_tool_calls.rb +26 -0
  30. data/app/models/raif/concerns/llms/bedrock/tool_formatting.rb +4 -0
  31. data/app/models/raif/concerns/llms/google/message_formatting.rb +109 -0
  32. data/app/models/raif/concerns/llms/google/response_tool_calls.rb +32 -0
  33. data/app/models/raif/concerns/llms/google/tool_formatting.rb +72 -0
  34. data/app/models/raif/concerns/llms/message_formatting.rb +11 -5
  35. data/app/models/raif/concerns/llms/open_ai/json_schema_validation.rb +3 -3
  36. data/app/models/raif/concerns/llms/open_ai_completions/message_formatting.rb +22 -0
  37. data/app/models/raif/concerns/llms/open_ai_completions/response_tool_calls.rb +22 -0
  38. data/app/models/raif/concerns/llms/open_ai_completions/tool_formatting.rb +4 -0
  39. data/app/models/raif/concerns/llms/open_ai_responses/message_formatting.rb +17 -0
  40. data/app/models/raif/concerns/llms/open_ai_responses/response_tool_calls.rb +26 -0
  41. data/app/models/raif/concerns/llms/open_ai_responses/tool_formatting.rb +4 -0
  42. data/app/models/raif/concerns/run_with.rb +127 -0
  43. data/app/models/raif/conversation.rb +91 -8
  44. data/app/models/raif/conversation_entry.rb +32 -1
  45. data/app/models/raif/embedding_model.rb +2 -1
  46. data/app/models/raif/embedding_models/open_ai.rb +1 -1
  47. data/app/models/raif/llm.rb +27 -2
  48. data/app/models/raif/llms/anthropic.rb +7 -19
  49. data/app/models/raif/llms/bedrock.rb +6 -20
  50. data/app/models/raif/llms/google.rb +140 -0
  51. data/app/models/raif/llms/open_ai_base.rb +19 -5
  52. data/app/models/raif/llms/open_ai_completions.rb +6 -11
  53. data/app/models/raif/llms/open_ai_responses.rb +6 -16
  54. data/app/models/raif/llms/open_router.rb +7 -13
  55. data/app/models/raif/model_completion.rb +61 -0
  56. data/app/models/raif/model_tool.rb +10 -2
  57. data/app/models/raif/model_tool_invocation.rb +38 -6
  58. data/app/models/raif/model_tools/agent_final_answer.rb +2 -7
  59. data/app/models/raif/model_tools/provider_managed/code_execution.rb +4 -0
  60. data/app/models/raif/model_tools/provider_managed/image_generation.rb +4 -0
  61. data/app/models/raif/model_tools/provider_managed/web_search.rb +4 -0
  62. data/app/models/raif/streaming_responses/google.rb +71 -0
  63. data/app/models/raif/task.rb +55 -12
  64. data/app/models/raif/user_tool_invocation.rb +19 -0
  65. data/app/views/layouts/raif/admin.html.erb +12 -1
  66. data/app/views/raif/admin/agents/_agent.html.erb +8 -0
  67. data/app/views/raif/admin/agents/_conversation_message.html.erb +28 -6
  68. data/app/views/raif/admin/agents/index.html.erb +2 -0
  69. data/app/views/raif/admin/agents/show.html.erb +46 -1
  70. data/app/views/raif/admin/configs/show.html.erb +117 -0
  71. data/app/views/raif/admin/conversations/_conversation_entry.html.erb +29 -34
  72. data/app/views/raif/admin/conversations/show.html.erb +2 -0
  73. data/app/views/raif/admin/model_completions/_model_completion.html.erb +9 -0
  74. data/app/views/raif/admin/model_completions/index.html.erb +26 -0
  75. data/app/views/raif/admin/model_completions/show.html.erb +124 -61
  76. data/app/views/raif/admin/model_tool_invocations/index.html.erb +22 -1
  77. data/app/views/raif/admin/model_tools/_list.html.erb +16 -0
  78. data/app/views/raif/admin/model_tools/_model_tool.html.erb +36 -0
  79. data/app/views/raif/admin/stats/_stats_tile.html.erb +34 -0
  80. data/app/views/raif/admin/stats/index.html.erb +71 -88
  81. data/app/views/raif/admin/stats/model_tool_invocations/index.html.erb +43 -0
  82. data/app/views/raif/admin/stats/tasks/index.html.erb +20 -6
  83. data/app/views/raif/admin/tasks/index.html.erb +6 -1
  84. data/app/views/raif/admin/tasks/show.html.erb +36 -3
  85. data/app/views/raif/conversation_entries/_form.html.erb +3 -0
  86. data/app/views/raif/conversations/_conversation.html.erb +10 -0
  87. data/app/views/raif/conversations/_entry_processed.turbo_stream.erb +12 -0
  88. data/app/views/raif/conversations/index.html.erb +23 -0
  89. data/config/locales/admin.en.yml +33 -1
  90. data/config/locales/en.yml +33 -4
  91. data/config/routes.rb +2 -0
  92. data/db/migrate/20250904194456_add_generating_entry_response_to_raif_conversations.rb +7 -0
  93. data/db/migrate/20250911125234_add_source_to_raif_tasks.rb +7 -0
  94. data/db/migrate/20251020005853_add_source_to_raif_agents.rb +7 -0
  95. data/db/migrate/20251020011346_rename_task_run_args_to_run_with.rb +7 -0
  96. data/db/migrate/20251020011405_add_run_with_to_raif_agents.rb +13 -0
  97. data/db/migrate/20251024160119_add_llm_messages_max_length_to_raif_conversations.rb +14 -0
  98. data/db/migrate/20251124185033_add_provider_tool_call_id_to_raif_model_tool_invocations.rb +7 -0
  99. data/db/migrate/20251128202941_add_tool_choice_to_raif_model_completions.rb +7 -0
  100. data/db/migrate/20260118144846_add_source_to_raif_conversations.rb +7 -0
  101. data/db/migrate/20260119000000_add_failure_tracking_to_raif_model_completions.rb +10 -0
  102. data/db/migrate/20260119000001_add_completed_at_to_raif_model_completions.rb +8 -0
  103. data/db/migrate/20260119000002_add_started_at_to_raif_model_completions.rb +8 -0
  104. data/lib/generators/raif/agent/templates/agent.rb.tt +1 -1
  105. data/lib/generators/raif/agent/templates/application_agent.rb.tt +1 -1
  106. data/lib/generators/raif/conversation/templates/conversation.rb.tt +6 -0
  107. data/lib/generators/raif/install/templates/initializer.rb +78 -10
  108. data/lib/generators/raif/task/templates/task.rb.tt +1 -1
  109. data/lib/raif/configuration.rb +37 -2
  110. data/lib/raif/engine.rb +8 -0
  111. data/lib/raif/errors/instance_dependent_schema_error.rb +8 -0
  112. data/lib/raif/errors/streaming_error.rb +6 -3
  113. data/lib/raif/errors.rb +1 -0
  114. data/lib/raif/evals/llm_judge.rb +2 -2
  115. data/lib/raif/evals/llm_judges/binary.rb +3 -3
  116. data/lib/raif/evals/llm_judges/comparative.rb +3 -3
  117. data/lib/raif/evals/llm_judges/scored.rb +1 -1
  118. data/lib/raif/evals/llm_judges/summarization.rb +2 -2
  119. data/lib/raif/evals/run.rb +1 -0
  120. data/lib/raif/json_schema_builder.rb +14 -0
  121. data/lib/raif/llm_registry.rb +207 -37
  122. data/lib/raif/messages.rb +180 -0
  123. data/lib/raif/version.rb +1 -1
  124. data/lib/raif.rb +9 -0
  125. data/lib/tasks/annotate_rb.rake +10 -0
  126. data/spec/support/rspec_helpers.rb +8 -8
  127. metadata +44 -9
  128. data/app/models/raif/agents/re_act_agent.rb +0 -127
  129. data/app/models/raif/agents/re_act_step.rb +0 -32
  130. data/app/models/raif/concerns/task_run_args.rb +0 -62
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::Llms::Google::ToolFormatting
4
+ extend ActiveSupport::Concern
5
+
6
+ def build_tools_parameter(model_completion)
7
+ tools = []
8
+ function_declarations = []
9
+
10
+ # If we support native tool use and have tools available, add them to the request
11
+ if supports_native_tool_use? && model_completion.available_model_tools.any?
12
+ model_completion.available_model_tools_map.each do |_tool_name, tool|
13
+ if tool.provider_managed?
14
+ # Provider-managed tools are added as separate tool entries
15
+ tools << format_provider_managed_tool(tool)
16
+ else
17
+ function_declarations << {
18
+ name: tool.tool_name,
19
+ description: tool.tool_description,
20
+ parameters: sanitize_schema_for_google(tool.tool_arguments_schema)
21
+ }
22
+ end
23
+ end
24
+ end
25
+
26
+ # Add function declarations if any
27
+ if function_declarations.any?
28
+ tools << { functionDeclarations: function_declarations }
29
+ end
30
+
31
+ tools
32
+ end
33
+
34
+ def format_provider_managed_tool(tool)
35
+ validate_provider_managed_tool_support!(tool)
36
+
37
+ case tool.name
38
+ when "Raif::ModelTools::ProviderManaged::WebSearch"
39
+ { google_search: {} }
40
+ when "Raif::ModelTools::ProviderManaged::CodeExecution"
41
+ { code_execution: {} }
42
+ else
43
+ raise Raif::Errors::UnsupportedFeatureError,
44
+ "Invalid provider-managed tool: #{tool.name} for #{key}"
45
+ end
46
+ end
47
+
48
+ def build_forced_tool_choice(tool_name)
49
+ { mode: "ANY", allowedFunctionNames: [tool_name] }
50
+ end
51
+
52
+ private
53
+
54
+ # Google's API doesn't support additionalProperties in JSON schemas
55
+ # This method recursively removes it from the schema
56
+ def sanitize_schema_for_google(schema)
57
+ return schema unless schema.is_a?(Hash)
58
+
59
+ sanitized = schema.except(:additionalProperties, "additionalProperties")
60
+
61
+ sanitized.transform_values do |value|
62
+ case value
63
+ when Hash
64
+ sanitize_schema_for_google(value)
65
+ when Array
66
+ value.map { |item| sanitize_schema_for_google(item) }
67
+ else
68
+ value
69
+ end
70
+ end
71
+ end
72
+ end
@@ -5,11 +5,17 @@ module Raif::Concerns::Llms::MessageFormatting
5
5
 
6
6
  def format_messages(messages)
7
7
  messages.map do |message|
8
- role = message["role"] || message[:role]
9
- {
10
- "role" => role,
11
- "content" => format_message_content(message["content"] || message[:content], role: role)
12
- }
8
+ if message.is_a?(Hash) && message["type"] == "tool_call"
9
+ format_tool_call_message(message)
10
+ elsif message.is_a?(Hash) && message["type"] == "tool_call_result"
11
+ format_tool_call_result_message(message)
12
+ else
13
+ role = message["role"] || message[:role]
14
+ {
15
+ "role" => role,
16
+ "content" => format_message_content(message["content"] || message[:content], role: role)
17
+ }
18
+ end
13
19
  end
14
20
  end
15
21
 
@@ -23,7 +23,7 @@ module Raif::Concerns::Llms::OpenAi::JsonSchemaValidation
23
23
  # Check properties count (max 100 total)
24
24
  validate_properties_count(schema, errors)
25
25
 
26
- # Check nesting depth (max 5 levels)
26
+ # Check nesting depth (max 10 levels)
27
27
  validate_nesting_depth(schema, errors)
28
28
 
29
29
  # Check for unsupported anyOf at root level
@@ -118,8 +118,8 @@ private
118
118
  def validate_nesting_depth(schema, errors, depth = 1)
119
119
  return unless schema.is_a?(Hash)
120
120
 
121
- if depth > 5
122
- errors << "Schema exceeds maximum nesting depth of 5 levels"
121
+ if depth > 10
122
+ errors << "Schema exceeds maximum nesting depth of 10 levels"
123
123
  return
124
124
  end
125
125
 
@@ -38,4 +38,26 @@ module Raif::Concerns::Llms::OpenAiCompletions::MessageFormatting
38
38
  raise Raif::Errors::InvalidModelFileInputError, "Invalid model image input source type: #{file_input.source_type}"
39
39
  end
40
40
  end
41
+
42
+ def format_tool_call_message(tool_call)
43
+ {
44
+ "role" => "assistant",
45
+ "tool_calls" => [{
46
+ "id" => tool_call["provider_tool_call_id"],
47
+ "type" => "function",
48
+ "function" => {
49
+ "name" => tool_call["name"],
50
+ "arguments" => JSON.generate(tool_call["arguments"])
51
+ }
52
+ }]
53
+ }
54
+ end
55
+
56
+ def format_tool_call_result_message(tool_call_result)
57
+ {
58
+ "role" => "tool",
59
+ "tool_call_id" => tool_call_result["provider_tool_call_id"],
60
+ "content" => tool_call_result["result"].is_a?(String) ? tool_call_result["result"] : JSON.generate(tool_call_result["result"])
61
+ }
62
+ end
41
63
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::Llms::OpenAiCompletions::ResponseToolCalls
4
+ extend ActiveSupport::Concern
5
+
6
+ def extract_response_tool_calls(resp)
7
+ tool_calls = resp.dig("choices", 0, "message", "tool_calls")
8
+ return if tool_calls.blank?
9
+
10
+ tool_calls.map do |tool_call|
11
+ {
12
+ "provider_tool_call_id" => tool_call["id"],
13
+ "name" => tool_call["function"]["name"],
14
+ "arguments" => begin
15
+ JSON.parse(tool_call["function"]["arguments"])
16
+ rescue JSON::ParserError
17
+ tool_call["function"]["arguments"]
18
+ end
19
+ }
20
+ end
21
+ end
22
+ end
@@ -23,4 +23,8 @@ module Raif::Concerns::Llms::OpenAiCompletions::ToolFormatting
23
23
  end
24
24
  end
25
25
  end
26
+
27
+ def build_forced_tool_choice(tool_name)
28
+ { "type" => "function", "function" => { "name" => tool_name } }
29
+ end
26
30
  end
@@ -40,4 +40,21 @@ module Raif::Concerns::Llms::OpenAiResponses::MessageFormatting
40
40
  raise Raif::Errors::InvalidModelFileInputError, "Invalid model image input source type: #{file_input.source_type}"
41
41
  end
42
42
  end
43
+
44
+ def format_tool_call_message(tool_call)
45
+ {
46
+ "type" => "function_call",
47
+ "call_id" => tool_call["provider_tool_call_id"],
48
+ "name" => tool_call["name"],
49
+ "arguments" => JSON.generate(tool_call["arguments"])
50
+ }
51
+ end
52
+
53
+ def format_tool_call_result_message(tool_call_result)
54
+ {
55
+ "type" => "function_call_output",
56
+ "call_id" => tool_call_result["provider_tool_call_id"],
57
+ "output" => tool_call_result["result"].is_a?(String) ? tool_call_result["result"] : JSON.generate(tool_call_result["result"])
58
+ }
59
+ end
43
60
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::Llms::OpenAiResponses::ResponseToolCalls
4
+ extend ActiveSupport::Concern
5
+
6
+ def extract_response_tool_calls(resp)
7
+ return if resp["output"].blank?
8
+
9
+ tool_calls = []
10
+ resp["output"].each do |output_item|
11
+ next unless output_item["type"] == "function_call"
12
+
13
+ tool_calls << {
14
+ "provider_tool_call_id" => output_item["call_id"],
15
+ "name" => output_item["name"],
16
+ "arguments" => begin
17
+ JSON.parse(output_item["arguments"])
18
+ rescue JSON::ParserError
19
+ output_item["arguments"]
20
+ end
21
+ }
22
+ end
23
+
24
+ tool_calls.any? ? tool_calls : nil
25
+ end
26
+ end
@@ -39,4 +39,8 @@ module Raif::Concerns::Llms::OpenAiResponses::ToolFormatting
39
39
  "Invalid provider-managed tool: #{tool.name} for #{key}"
40
40
  end
41
41
  end
42
+
43
+ def build_forced_tool_choice(tool_name)
44
+ { "type" => "function", "name" => tool_name }
45
+ end
42
46
  end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::RunWith
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ class_attribute :_run_with_args, instance_writer: false, default: []
8
+
9
+ # Backward compatibility alias
10
+ class_attribute :_task_run_args, instance_writer: false, default: []
11
+
12
+ # Automatically serialize run_with args before validation on create
13
+ before_validation :serialize_run_with_to_column, on: :create
14
+ end
15
+
16
+ class_methods do
17
+ # Scope for querying records by run_with arguments
18
+ # @param args [Hash] Key-value pairs to match in the run_with column
19
+ # @example
20
+ # Task.having_run_with(document: doc)
21
+ # Task.having_run_with(user: user, options: { foo: "bar" })
22
+ def having_run_with(**args)
23
+ return all if args.empty?
24
+
25
+ # Serialize args the same way we do for storage (handles GID conversion)
26
+ serialized = serialize_run_with(args)
27
+
28
+ # Avoid matching all records if args didn't match declared run_with arguments
29
+ return none if args.any? && serialized.empty?
30
+
31
+ # Use database-specific JSON containment query
32
+ case connection.adapter_name.downcase
33
+ when "postgresql"
34
+ # PostgreSQL: Use JSONB containment operator
35
+ where("run_with @> ?", serialized.to_json)
36
+ when "mysql2", "trilogy"
37
+ # MySQL: Use JSON_CONTAINS function
38
+ where("JSON_CONTAINS(run_with, ?)", serialized.to_json)
39
+ else
40
+ raise "Unsupported database: #{connection.adapter_name}"
41
+ end
42
+ end
43
+
44
+ # DSL for declaring persistent run arguments that will be serialized to the database
45
+ # @param name [Symbol] The name of the argument
46
+ def run_with(name)
47
+ # Ensure each class has its own array copy
48
+ self._run_with_args = _run_with_args.dup
49
+ _run_with_args << name.to_sym
50
+
51
+ # Keep backward compatibility for _task_run_args class attribute
52
+ self._task_run_args = _task_run_args.dup
53
+ _task_run_args << name.to_sym
54
+
55
+ # Define getter that pulls from run_with JSON column
56
+ define_method(name) do
57
+ return instance_variable_get("@#{name}") if instance_variable_defined?("@#{name}")
58
+
59
+ value = run_with&.dig(name.to_s)
60
+ return unless value
61
+
62
+ # Deserialize GID if it's a string starting with gid://
63
+ deserialized = if value.is_a?(String) && value.start_with?("gid://")
64
+ begin
65
+ GlobalID::Locator.locate(value)
66
+ rescue ActiveRecord::RecordNotFound
67
+ nil
68
+ end
69
+ else
70
+ value
71
+ end
72
+
73
+ instance_variable_set("@#{name}", deserialized)
74
+ end
75
+
76
+ # Define setter that stores in memory (for use during run)
77
+ define_method("#{name}=") do |value|
78
+ instance_variable_set("@#{name}", value)
79
+ end
80
+ end
81
+
82
+ # Backward compatibility alias
83
+ alias_method :task_run_arg, :run_with
84
+
85
+ # Transform run args into a hash that can be stored in the run_with database column
86
+ def serialize_run_with(args)
87
+ serialized_args = {}
88
+ _run_with_args.each do |arg_name|
89
+ next unless args.key?(arg_name)
90
+
91
+ value = args[arg_name]
92
+ serialized_args[arg_name.to_s] = if value.respond_to?(:to_global_id)
93
+ value.to_global_id.to_s
94
+ else
95
+ value
96
+ end
97
+ end
98
+
99
+ serialized_args
100
+ end
101
+
102
+ # Backward compatibility alias
103
+ alias_method :serialize_task_run_args, :serialize_run_with
104
+ end
105
+
106
+ private
107
+
108
+ # Automatically called before validation on create to serialize run_with args
109
+ # Collects all declared run_with arguments from instance variables and serializes them
110
+ # to the run_with JSON column
111
+ def serialize_run_with_to_column
112
+ args = {}
113
+
114
+ # Collect all run_with args that were set via instance variables
115
+ self.class._run_with_args.each do |arg_name|
116
+ if instance_variable_defined?("@#{arg_name}")
117
+ args[arg_name] = instance_variable_get("@#{arg_name}")
118
+ end
119
+ end
120
+
121
+ # Merge serialized args into run_with hash if any args were set
122
+ if args.any?
123
+ self.run_with ||= {}
124
+ self.run_with = self.run_with.merge(self.class.serialize_run_with(args))
125
+ end
126
+ end
127
+ end
@@ -1,5 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # == Schema Information
4
+ #
5
+ # Table name: raif_conversations
6
+ #
7
+ # id :bigint not null, primary key
8
+ # available_model_tools :jsonb not null
9
+ # available_user_tools :jsonb not null
10
+ # conversation_entries_count :integer default(0), not null
11
+ # creator_type :string not null
12
+ # generating_entry_response :boolean default(FALSE), not null
13
+ # llm_messages_max_length :integer
14
+ # llm_model_key :string not null
15
+ # requested_language_key :string
16
+ # response_format :integer default("text"), not null
17
+ # source_type :string
18
+ # system_prompt :text
19
+ # type :string not null
20
+ # created_at :datetime not null
21
+ # updated_at :datetime not null
22
+ # creator_id :bigint not null
23
+ # source_id :bigint
24
+ #
25
+ # Indexes
26
+ #
27
+ # index_raif_conversations_on_created_at (created_at)
28
+ # index_raif_conversations_on_creator (creator_type,creator_id)
29
+ # index_raif_conversations_on_source (source_type,source_id)
30
+ #
3
31
  class Raif::Conversation < Raif::ApplicationRecord
4
32
  include Raif::Concerns::HasLlm
5
33
  include Raif::Concerns::HasRequestedLanguage
@@ -7,6 +35,27 @@ class Raif::Conversation < Raif::ApplicationRecord
7
35
  include Raif::Concerns::LlmResponseParsing
8
36
 
9
37
  belongs_to :creator, polymorphic: true
38
+ belongs_to :source, polymorphic: true, optional: true
39
+
40
+ class << self
41
+ def before_prompt_model_for_entry_response(&block)
42
+ @before_prompt_model_for_entry_response_blocks ||= []
43
+ @before_prompt_model_for_entry_response_blocks << block if block
44
+ end
45
+
46
+ def before_prompt_model_for_entry_response_blocks
47
+ blocks = []
48
+
49
+ # Collect blocks from ancestors (in reverse order so parent blocks run first)
50
+ ancestors.reverse_each do |klass|
51
+ if klass.instance_variable_defined?(:@before_prompt_model_for_entry_response_blocks)
52
+ blocks.concat(klass.instance_variable_get(:@before_prompt_model_for_entry_response_blocks))
53
+ end
54
+ end
55
+
56
+ blocks
57
+ end
58
+ end
10
59
 
11
60
  has_many :entries, class_name: "Raif::ConversationEntry", dependent: :destroy, foreign_key: :raif_conversation_id, inverse_of: :raif_conversation
12
61
 
@@ -14,6 +63,7 @@ class Raif::Conversation < Raif::ApplicationRecord
14
63
 
15
64
  after_initialize -> { self.available_model_tools ||= [] }
16
65
  after_initialize -> { self.available_user_tools ||= [] }
66
+ after_initialize -> { self.llm_messages_max_length ||= Raif.config.conversation_llm_messages_max_length_default }
17
67
 
18
68
  before_validation ->{ self.type ||= "Raif::Conversation" }, on: :create
19
69
 
@@ -39,9 +89,15 @@ class Raif::Conversation < Raif::ApplicationRecord
39
89
  end
40
90
 
41
91
  def prompt_model_for_entry_response(entry:, &block)
42
- update(system_prompt: build_system_prompt)
92
+ self.class.before_prompt_model_for_entry_response_blocks.each do |callback_block|
93
+ instance_exec(entry, &callback_block)
94
+ end
95
+
96
+ self.system_prompt = build_system_prompt
97
+ self.generating_entry_response = true
98
+ save!
43
99
 
44
- llm.chat(
100
+ model_completion = llm.chat(
45
101
  messages: llm_messages,
46
102
  source: entry,
47
103
  response_format: response_format.to_sym,
@@ -49,7 +105,15 @@ class Raif::Conversation < Raif::ApplicationRecord
49
105
  available_model_tools: available_model_tools,
50
106
  &block
51
107
  )
108
+
109
+ self.generating_entry_response = false
110
+ save!
111
+
112
+ model_completion
52
113
  rescue StandardError => e
114
+ self.generating_entry_response = false
115
+ save!
116
+
53
117
  Rails.logger.error("Error processing conversation entry ##{entry.id}. #{e.message}")
54
118
  Rails.logger.error(e.backtrace.join("\n"))
55
119
 
@@ -75,14 +139,33 @@ class Raif::Conversation < Raif::ApplicationRecord
75
139
  def llm_messages
76
140
  messages = []
77
141
 
78
- entries.oldest_first.includes(:raif_model_tool_invocations).each do |entry|
79
- messages << { "role" => "user", "content" => entry.user_message } unless entry.user_message.blank?
142
+ # Apply max length limit to entries if configured (nil means no limit)
143
+ included_entries = entries.oldest_first.includes(:raif_model_tool_invocations)
144
+ included_entries = included_entries.last(llm_messages_max_length) if llm_messages_max_length.present?
145
+
146
+ included_entries.each do |entry|
147
+ unless entry.user_message.blank?
148
+ messages << Raif::Messages::UserMessage.new(content: entry.user_message).to_h
149
+ end
150
+
80
151
  next unless entry.completed?
81
152
 
82
- messages << { "role" => "assistant", "content" => entry.model_response_message } unless entry.model_response_message.blank?
83
- entry.raif_model_tool_invocations.each do |tool_invocation|
84
- messages << { "role" => "assistant", "content" => tool_invocation.as_llm_message }
85
- messages << { "role" => "assistant", "content" => tool_invocation.result_llm_message } if tool_invocation.result_llm_message.present?
153
+ tool_invocations = entry.raif_model_tool_invocations.to_a
154
+
155
+ if tool_invocations.any?
156
+ # First tool call includes the assistant's message (if any)
157
+ first_invocation = tool_invocations.shift
158
+ messages << first_invocation.as_tool_call_message(assistant_message: entry.model_response_message.presence)
159
+ messages << first_invocation.as_tool_call_result_message
160
+
161
+ # Remaining tool calls (if multiple)
162
+ tool_invocations.each do |tool_invocation|
163
+ messages << tool_invocation.as_tool_call_message
164
+ messages << tool_invocation.as_tool_call_result_message
165
+ end
166
+ elsif entry.model_response_message.present?
167
+ # No tool calls, just a regular assistant response
168
+ messages << Raif::Messages::AssistantMessage.new(content: entry.model_response_message).to_h
86
169
  end
87
170
  end
88
171
 
@@ -1,5 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # == Schema Information
4
+ #
5
+ # Table name: raif_conversation_entries
6
+ #
7
+ # id :bigint not null, primary key
8
+ # completed_at :datetime
9
+ # creator_type :string not null
10
+ # failed_at :datetime
11
+ # model_response_message :text
12
+ # raw_response :text
13
+ # started_at :datetime
14
+ # user_message :text
15
+ # created_at :datetime not null
16
+ # updated_at :datetime not null
17
+ # creator_id :bigint not null
18
+ # raif_conversation_id :bigint not null
19
+ #
20
+ # Indexes
21
+ #
22
+ # index_raif_conversation_entries_on_created_at (created_at)
23
+ # index_raif_conversation_entries_on_creator (creator_type,creator_id)
24
+ # index_raif_conversation_entries_on_raif_conversation_id (raif_conversation_id)
25
+ #
26
+ # Foreign Keys
27
+ #
28
+ # fk_rails_... (raif_conversation_id => raif_conversations.id)
29
+ #
3
30
  class Raif::ConversationEntry < Raif::ApplicationRecord
4
31
  include Raif::Concerns::InvokesModelTools
5
32
  include Raif::Concerns::HasAvailableModelTools
@@ -98,7 +125,11 @@ private
98
125
  tool_klass = available_model_tools_map[tool_call["name"]]
99
126
  next if tool_klass.nil?
100
127
 
101
- tool_klass.invoke_tool(tool_arguments: tool_call["arguments"], source: self)
128
+ tool_klass.invoke_tool(
129
+ provider_tool_call_id: tool_call["provider_tool_call_id"],
130
+ tool_arguments: tool_call["arguments"],
131
+ source: self
132
+ )
102
133
  end
103
134
 
104
135
  completed!
@@ -5,6 +5,7 @@ class Raif::EmbeddingModel
5
5
 
6
6
  attr_accessor :key,
7
7
  :api_name,
8
+ :display_name,
8
9
  :input_token_cost,
9
10
  :default_output_vector_size
10
11
 
@@ -13,7 +14,7 @@ class Raif::EmbeddingModel
13
14
  validates :key, presence: true
14
15
 
15
16
  def name
16
- I18n.t("raif.embedding_model_names.#{key}")
17
+ I18n.t("raif.embedding_model_names.#{key}", default: display_name || key.to_s.humanize)
17
18
  end
18
19
 
19
20
  def generate_embedding!(input, dimensions: nil)
@@ -30,7 +30,7 @@ private
30
30
  end
31
31
 
32
32
  def connection
33
- @connection ||= Faraday.new(url: "https://api.openai.com/v1") do |f|
33
+ @connection ||= Faraday.new(url: Raif.config.open_ai_embedding_base_url, request: Raif.default_request_options) do |f|
34
34
  f.headers["Authorization"] = "Bearer #{Raif.config.open_ai_api_key}"
35
35
  f.request :json
36
36
  f.response :json
@@ -7,6 +7,7 @@ module Raif
7
7
 
8
8
  attr_accessor :key,
9
9
  :api_name,
10
+ :display_name,
10
11
  :default_temperature,
11
12
  :default_max_completion_tokens,
12
13
  :supports_native_tool_use,
@@ -25,6 +26,7 @@ module Raif
25
26
  def initialize(
26
27
  key:,
27
28
  api_name:,
29
+ display_name: nil,
28
30
  model_provider_settings: {},
29
31
  supported_provider_managed_tools: [],
30
32
  supports_native_tool_use: true,
@@ -35,6 +37,7 @@ module Raif
35
37
  )
36
38
  @key = key
37
39
  @api_name = api_name
40
+ @display_name = display_name
38
41
  @provider_settings = model_provider_settings
39
42
  @supports_native_tool_use = supports_native_tool_use
40
43
  @default_temperature = temperature || 0.7
@@ -45,11 +48,11 @@ module Raif
45
48
  end
46
49
 
47
50
  def name
48
- I18n.t("raif.model_names.#{key}")
51
+ I18n.t("raif.model_names.#{key}", default: display_name || key.to_s.humanize)
49
52
  end
50
53
 
51
54
  def chat(message: nil, messages: nil, response_format: :text, available_model_tools: [], source: nil, system_prompt: nil, temperature: nil,
52
- max_completion_tokens: nil, &block)
55
+ max_completion_tokens: nil, tool_choice: nil, &block)
53
56
  unless response_format.is_a?(Symbol)
54
57
  raise ArgumentError,
55
58
  "Raif::Llm#chat - Invalid response format: #{response_format}. Must be a symbol (you passed #{response_format.class}) and be one of: #{VALID_RESPONSE_FORMATS.join(", ")}" # rubocop:disable Layout/LineLength
@@ -67,6 +70,11 @@ module Raif
67
70
  raise ArgumentError, "Raif::Llm#chat - You must provide either a message: or messages: argument, not both"
68
71
  end
69
72
 
73
+ if tool_choice.present? && !available_model_tools.map(&:to_s).include?(tool_choice.to_s)
74
+ raise ArgumentError,
75
+ "Raif::Llm#chat - Invalid tool choice: #{tool_choice} is not included in the available model tools: #{available_model_tools.join(", ")}"
76
+ end
77
+
70
78
  unless Raif.config.llm_api_requests_enabled
71
79
  Raif.logger.warn("LLM API requests are disabled. Skipping request to #{api_name}.")
72
80
  return
@@ -87,20 +95,29 @@ module Raif
87
95
  temperature: temperature,
88
96
  max_completion_tokens: max_completion_tokens,
89
97
  available_model_tools: available_model_tools,
98
+ tool_choice: tool_choice&.to_s,
90
99
  stream_response: block_given?
91
100
  )
92
101
 
102
+ model_completion.started!
103
+
93
104
  retry_with_backoff(model_completion) do
94
105
  perform_model_completion!(model_completion, &block)
95
106
  end
96
107
 
108
+ model_completion.completed!
97
109
  model_completion
98
110
  rescue Raif::Errors::StreamingError => e
99
111
  Rails.logger.error("Raif streaming error -- code: #{e.code} -- type: #{e.type} -- message: #{e.message} -- event: #{e.event}")
112
+ model_completion&.record_failure!(e)
100
113
  raise e
101
114
  rescue Faraday::Error => e
102
115
  Raif.logger.error("LLM API request failed (status: #{e.response_status}): #{e.message}")
103
116
  Raif.logger.error(e.response_body)
117
+ model_completion&.record_failure!(e)
118
+ raise e
119
+ rescue StandardError => e
120
+ model_completion&.record_failure!(e)
104
121
  raise e
105
122
  end
106
123
 
@@ -116,6 +133,14 @@ module Raif
116
133
  supported_provider_managed_tools&.include?(tool_klass.to_s)
117
134
  end
118
135
 
136
+ # Build the tool_choice parameter to force a specific tool to be called.
137
+ # Each provider implements this to return the correct format.
138
+ # @param tool_name [String] The name of the tool to force
139
+ # @return [Hash] The tool_choice parameter for the provider's API
140
+ def build_forced_tool_choice(tool_name)
141
+ raise NotImplementedError, "#{self.class.name} must implement #build_forced_tool_choice"
142
+ end
143
+
119
144
  def validate_provider_managed_tool_support!(tool)
120
145
  unless supports_provider_managed_tool?(tool)
121
146
  raise Raif::Errors::UnsupportedFeatureError,