raif 1.3.0 → 1.5.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 (206) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -7
  3. data/app/assets/builds/raif.css +4 -1
  4. data/app/assets/builds/raif_admin.css +52 -2
  5. data/app/assets/builds/raif_admin_sprockets.js +2709 -0
  6. data/app/assets/javascript/raif/admin/copy_to_clipboard_controller.js +132 -0
  7. data/app/assets/javascript/raif/admin/cost_estimate_controller.js +80 -0
  8. data/app/assets/javascript/raif/admin/judge_config_controller.js +23 -0
  9. data/app/assets/javascript/raif/admin/select_all_checkboxes_controller.js +33 -0
  10. data/app/assets/javascript/raif/admin/sortable_table_controller.js +51 -0
  11. data/app/assets/javascript/raif/admin/table_search_controller.js +15 -0
  12. data/app/assets/javascript/raif/admin/tom_select_controller.js +33 -0
  13. data/app/assets/javascript/raif/controllers/conversations_controller.js +1 -1
  14. data/app/assets/javascript/raif_admin.js +23 -0
  15. data/app/assets/javascript/raif_admin_sprockets.js +24 -0
  16. data/app/assets/stylesheets/raif/admin/conversation.scss +16 -0
  17. data/app/assets/stylesheets/raif/conversations.scss +3 -0
  18. data/app/assets/stylesheets/raif.scss +2 -1
  19. data/app/assets/stylesheets/raif_admin.scss +50 -1
  20. data/app/controllers/raif/admin/agents_controller.rb +27 -1
  21. data/app/controllers/raif/admin/application_controller.rb +16 -0
  22. data/app/controllers/raif/admin/configs_controller.rb +95 -0
  23. data/app/controllers/raif/admin/llms_controller.rb +27 -0
  24. data/app/controllers/raif/admin/model_completions_controller.rb +24 -1
  25. data/app/controllers/raif/admin/model_tool_invocations_controller.rb +7 -1
  26. data/app/controllers/raif/admin/prompt_studio/agents_controller.rb +25 -0
  27. data/app/controllers/raif/admin/prompt_studio/base_controller.rb +32 -0
  28. data/app/controllers/raif/admin/prompt_studio/batch_runs_controller.rb +102 -0
  29. data/app/controllers/raif/admin/prompt_studio/conversations_controller.rb +25 -0
  30. data/app/controllers/raif/admin/prompt_studio/tasks_controller.rb +64 -0
  31. data/app/controllers/raif/admin/stats/model_tool_invocations_controller.rb +21 -0
  32. data/app/controllers/raif/admin/stats/tasks_controller.rb +15 -6
  33. data/app/controllers/raif/admin/stats_controller.rb +32 -3
  34. data/app/controllers/raif/admin/tasks_controller.rb +5 -0
  35. data/app/controllers/raif/conversation_entries_controller.rb +1 -0
  36. data/app/controllers/raif/conversations_controller.rb +10 -2
  37. data/app/helpers/raif/application_helper.rb +40 -0
  38. data/app/jobs/raif/conversation_entry_job.rb +8 -6
  39. data/app/jobs/raif/prompt_studio_batch_run_item_job.rb +11 -0
  40. data/app/jobs/raif/prompt_studio_batch_run_job.rb +15 -0
  41. data/app/jobs/raif/prompt_studio_task_run_job.rb +36 -0
  42. data/app/models/raif/admin/task_stat.rb +7 -0
  43. data/app/models/raif/agent.rb +98 -6
  44. data/app/models/raif/agents/native_tool_calling_agent.rb +179 -52
  45. data/app/models/raif/application_record.rb +18 -0
  46. data/app/models/raif/concerns/agent_inference_stats.rb +35 -0
  47. data/app/models/raif/concerns/has_prompt_templates.rb +88 -0
  48. data/app/models/raif/concerns/has_runtime_duration.rb +41 -0
  49. data/app/models/raif/concerns/json_schema_definition.rb +54 -6
  50. data/app/models/raif/concerns/llm_prompt_caching.rb +20 -0
  51. data/app/models/raif/concerns/llms/anthropic/message_formatting.rb +34 -0
  52. data/app/models/raif/concerns/llms/anthropic/response_tool_calls.rb +24 -0
  53. data/app/models/raif/concerns/llms/anthropic/tool_formatting.rb +8 -0
  54. data/app/models/raif/concerns/llms/bedrock/message_formatting.rb +43 -0
  55. data/app/models/raif/concerns/llms/bedrock/response_tool_calls.rb +26 -0
  56. data/app/models/raif/concerns/llms/bedrock/tool_formatting.rb +8 -0
  57. data/app/models/raif/concerns/llms/google/message_formatting.rb +112 -0
  58. data/app/models/raif/concerns/llms/google/response_tool_calls.rb +32 -0
  59. data/app/models/raif/concerns/llms/google/tool_formatting.rb +76 -0
  60. data/app/models/raif/concerns/llms/message_formatting.rb +41 -5
  61. data/app/models/raif/concerns/llms/open_ai/json_schema_validation.rb +3 -3
  62. data/app/models/raif/concerns/llms/open_ai_completions/message_formatting.rb +22 -0
  63. data/app/models/raif/concerns/llms/open_ai_completions/response_tool_calls.rb +22 -0
  64. data/app/models/raif/concerns/llms/open_ai_completions/tool_formatting.rb +8 -0
  65. data/app/models/raif/concerns/llms/open_ai_responses/message_formatting.rb +17 -0
  66. data/app/models/raif/concerns/llms/open_ai_responses/response_tool_calls.rb +26 -0
  67. data/app/models/raif/concerns/llms/open_ai_responses/tool_formatting.rb +8 -0
  68. data/app/models/raif/concerns/provider_managed_tool_calls.rb +162 -0
  69. data/app/models/raif/concerns/run_with.rb +127 -0
  70. data/app/models/raif/conversation.rb +112 -8
  71. data/app/models/raif/conversation_entry.rb +38 -4
  72. data/app/models/raif/embedding_model.rb +2 -1
  73. data/app/models/raif/embedding_models/bedrock.rb +10 -1
  74. data/app/models/raif/embedding_models/google.rb +37 -0
  75. data/app/models/raif/embedding_models/open_ai.rb +1 -1
  76. data/app/models/raif/evals/llm_judge.rb +70 -0
  77. data/{lib → app/models}/raif/evals/llm_judges/binary.rb +41 -3
  78. data/{lib → app/models}/raif/evals/llm_judges/comparative.rb +41 -3
  79. data/{lib → app/models}/raif/evals/llm_judges/scored.rb +39 -1
  80. data/{lib → app/models}/raif/evals/llm_judges/summarization.rb +40 -2
  81. data/app/models/raif/llm.rb +104 -4
  82. data/app/models/raif/llms/anthropic.rb +32 -22
  83. data/app/models/raif/llms/bedrock.rb +64 -24
  84. data/app/models/raif/llms/google.rb +166 -0
  85. data/app/models/raif/llms/open_ai_base.rb +23 -5
  86. data/app/models/raif/llms/open_ai_completions.rb +14 -12
  87. data/app/models/raif/llms/open_ai_responses.rb +14 -17
  88. data/app/models/raif/llms/open_router.rb +16 -15
  89. data/app/models/raif/model_completion.rb +103 -1
  90. data/app/models/raif/model_tool.rb +55 -5
  91. data/app/models/raif/model_tool_invocation.rb +68 -6
  92. data/app/models/raif/model_tools/agent_final_answer.rb +2 -7
  93. data/app/models/raif/model_tools/provider_managed/code_execution.rb +4 -0
  94. data/app/models/raif/model_tools/provider_managed/image_generation.rb +4 -0
  95. data/app/models/raif/model_tools/provider_managed/web_search.rb +4 -0
  96. data/app/models/raif/prompt_studio_batch_run.rb +155 -0
  97. data/app/models/raif/prompt_studio_batch_run_item.rb +220 -0
  98. data/app/models/raif/streaming_responses/bedrock.rb +60 -1
  99. data/app/models/raif/streaming_responses/google.rb +71 -0
  100. data/app/models/raif/task.rb +85 -18
  101. data/app/models/raif/user_tool_invocation.rb +19 -0
  102. data/app/views/layouts/raif/admin.html.erb +43 -2
  103. data/app/views/raif/admin/agents/_agent.html.erb +9 -0
  104. data/app/views/raif/admin/agents/_conversation_message.html.erb +28 -6
  105. data/app/views/raif/admin/agents/index.html.erb +50 -0
  106. data/app/views/raif/admin/agents/show.html.erb +50 -1
  107. data/app/views/raif/admin/configs/show.html.erb +117 -0
  108. data/app/views/raif/admin/conversations/_conversation_entry.html.erb +29 -34
  109. data/app/views/raif/admin/conversations/show.html.erb +2 -0
  110. data/app/views/raif/admin/llms/index.html.erb +110 -0
  111. data/app/views/raif/admin/model_completions/_model_completion.html.erb +10 -5
  112. data/app/views/raif/admin/model_completions/index.html.erb +40 -1
  113. data/app/views/raif/admin/model_completions/show.html.erb +256 -84
  114. data/app/views/raif/admin/model_tool_invocations/index.html.erb +22 -1
  115. data/app/views/raif/admin/model_tool_invocations/show.html.erb +18 -0
  116. data/app/views/raif/admin/model_tools/_list.html.erb +16 -0
  117. data/app/views/raif/admin/model_tools/_model_tool.html.erb +36 -0
  118. data/app/views/raif/admin/prompt_studio/agents/index.html.erb +56 -0
  119. data/app/views/raif/admin/prompt_studio/agents/show.html.erb +57 -0
  120. data/app/views/raif/admin/prompt_studio/batch_runs/_batch_run_item.html.erb +54 -0
  121. data/app/views/raif/admin/prompt_studio/batch_runs/_judge_config_fields.html.erb +76 -0
  122. data/app/views/raif/admin/prompt_studio/batch_runs/_judge_detail_modal.html.erb +27 -0
  123. data/app/views/raif/admin/prompt_studio/batch_runs/_modal.html.erb +35 -0
  124. data/app/views/raif/admin/prompt_studio/batch_runs/_progress.html.erb +78 -0
  125. data/app/views/raif/admin/prompt_studio/batch_runs/show.html.erb +49 -0
  126. data/app/views/raif/admin/prompt_studio/conversations/index.html.erb +48 -0
  127. data/app/views/raif/admin/prompt_studio/conversations/show.html.erb +36 -0
  128. data/app/views/raif/admin/prompt_studio/shared/_nav_tabs.html.erb +17 -0
  129. data/app/views/raif/admin/prompt_studio/shared/_prompt_comparison.html.erb +87 -0
  130. data/app/views/raif/admin/prompt_studio/shared/_type_filter.html.erb +54 -0
  131. data/app/views/raif/admin/prompt_studio/tasks/_task_result.html.erb +145 -0
  132. data/app/views/raif/admin/prompt_studio/tasks/_task_row.html.erb +12 -0
  133. data/app/views/raif/admin/prompt_studio/tasks/_task_type_filter.html.erb +58 -0
  134. data/app/views/raif/admin/prompt_studio/tasks/_tasks_table.html.erb +22 -0
  135. data/app/views/raif/admin/prompt_studio/tasks/index.html.erb +35 -0
  136. data/app/views/raif/admin/prompt_studio/tasks/show.html.erb +19 -0
  137. data/app/views/raif/admin/stats/_stats_tile.html.erb +34 -0
  138. data/app/views/raif/admin/stats/index.html.erb +71 -88
  139. data/app/views/raif/admin/stats/model_tool_invocations/index.html.erb +43 -0
  140. data/app/views/raif/admin/stats/tasks/index.html.erb +20 -6
  141. data/app/views/raif/admin/tasks/_task.html.erb +1 -0
  142. data/app/views/raif/admin/tasks/index.html.erb +23 -6
  143. data/app/views/raif/admin/tasks/show.html.erb +56 -3
  144. data/app/views/raif/conversation_entries/_form.html.erb +3 -0
  145. data/app/views/raif/conversation_entries/_message.html.erb +10 -6
  146. data/app/views/raif/conversations/_conversation.html.erb +10 -0
  147. data/app/views/raif/conversations/_entry_processed.turbo_stream.erb +12 -0
  148. data/app/views/raif/conversations/index.html.erb +23 -0
  149. data/config/importmap.rb +8 -0
  150. data/config/locales/admin.en.yml +161 -1
  151. data/config/locales/en.yml +67 -4
  152. data/config/routes.rb +10 -0
  153. data/db/migrate/20250904194456_add_generating_entry_response_to_raif_conversations.rb +7 -0
  154. data/db/migrate/20250911125234_add_source_to_raif_tasks.rb +7 -0
  155. data/db/migrate/20251020005853_add_source_to_raif_agents.rb +7 -0
  156. data/db/migrate/20251020011346_rename_task_run_args_to_run_with.rb +7 -0
  157. data/db/migrate/20251020011405_add_run_with_to_raif_agents.rb +13 -0
  158. data/db/migrate/20251024160119_add_llm_messages_max_length_to_raif_conversations.rb +14 -0
  159. data/db/migrate/20251124185033_add_provider_tool_call_id_to_raif_model_tool_invocations.rb +7 -0
  160. data/db/migrate/20251128202941_add_tool_choice_to_raif_model_completions.rb +7 -0
  161. data/db/migrate/20260118144846_add_source_to_raif_conversations.rb +7 -0
  162. data/db/migrate/20260119000000_add_failure_tracking_to_raif_model_completions.rb +10 -0
  163. data/db/migrate/20260119000001_add_completed_at_to_raif_model_completions.rb +8 -0
  164. data/db/migrate/20260119000002_add_started_at_to_raif_model_completions.rb +8 -0
  165. data/db/migrate/20260307000000_add_prompt_studio_run_to_raif_tasks.rb +7 -0
  166. data/db/migrate/20260308000000_create_raif_prompt_studio_batch_runs.rb +27 -0
  167. data/db/migrate/20260308000001_create_raif_prompt_studio_batch_run_items.rb +24 -0
  168. data/db/migrate/20260407000000_add_cache_token_columns_to_raif_model_completions.rb +8 -0
  169. data/lib/generators/raif/agent/agent_generator.rb +18 -0
  170. data/lib/generators/raif/agent/templates/agent.rb.tt +7 -5
  171. data/lib/generators/raif/agent/templates/application_agent.rb.tt +1 -1
  172. data/lib/generators/raif/agent/templates/system_prompt.erb.tt +3 -0
  173. data/lib/generators/raif/conversation/conversation_generator.rb +19 -1
  174. data/lib/generators/raif/conversation/templates/conversation.rb.tt +6 -0
  175. data/lib/generators/raif/conversation/templates/system_prompt.erb.tt +4 -0
  176. data/lib/generators/raif/install/templates/initializer.rb +117 -8
  177. data/lib/generators/raif/task/task_generator.rb +18 -0
  178. data/lib/generators/raif/task/templates/prompt.erb.tt +4 -0
  179. data/lib/generators/raif/task/templates/task.rb.tt +10 -9
  180. data/lib/raif/configuration.rb +47 -2
  181. data/lib/raif/embedding_model_registry.rb +8 -0
  182. data/lib/raif/engine.rb +24 -1
  183. data/lib/raif/errors/blank_response_error.rb +8 -0
  184. data/lib/raif/errors/instance_dependent_schema_error.rb +8 -0
  185. data/lib/raif/errors/prompt_template_error.rb +15 -0
  186. data/lib/raif/errors/streaming_error.rb +6 -3
  187. data/lib/raif/errors.rb +3 -0
  188. data/lib/raif/evals/run.rb +1 -0
  189. data/lib/raif/evals.rb +0 -6
  190. data/lib/raif/json_schema_builder.rb +14 -0
  191. data/lib/raif/llm_registry.rb +433 -42
  192. data/lib/raif/messages.rb +180 -0
  193. data/lib/raif/prompt_studio_comparison_builder.rb +138 -0
  194. data/lib/raif/token_estimator.rb +28 -0
  195. data/lib/raif/version.rb +1 -1
  196. data/lib/raif.rb +11 -0
  197. data/lib/tasks/annotate_rb.rake +10 -0
  198. data/spec/support/rspec_helpers.rb +15 -9
  199. data/spec/support/test_task.rb +9 -0
  200. data/spec/support/test_template_task.rb +41 -0
  201. metadata +108 -15
  202. data/app/models/raif/agents/re_act_agent.rb +0 -127
  203. data/app/models/raif/agents/re_act_step.rb +0 -32
  204. data/app/models/raif/concerns/task_run_args.rb +0 -62
  205. data/lib/raif/evals/llm_judge.rb +0 -32
  206. /data/{lib → app/models}/raif/evals/scoring_rubric.rb +0 -0
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::HasRuntimeDuration
4
+ extend ActiveSupport::Concern
5
+
6
+ def runtime_ended_at
7
+ completed_at || failed_at
8
+ end
9
+
10
+ def runtime_duration_seconds
11
+ return if started_at.blank? || runtime_ended_at.blank?
12
+
13
+ duration_in_seconds = runtime_ended_at - started_at
14
+ return if duration_in_seconds.negative?
15
+
16
+ duration_in_seconds
17
+ end
18
+
19
+ def runtime_duration
20
+ duration_in_seconds = runtime_duration_seconds
21
+ return "-" if duration_in_seconds.nil?
22
+
23
+ if duration_in_seconds < 1
24
+ "#{(duration_in_seconds * 1000).round}ms"
25
+ elsif duration_in_seconds < 60
26
+ seconds = (duration_in_seconds * 100).round / 100.0
27
+ "#{seconds.to_s.sub(/\.0+\z/, "").sub(/(\.\d*[1-9])0+\z/, "\\1")}s"
28
+ else
29
+ total_seconds = duration_in_seconds.round
30
+ hours = total_seconds / 3600
31
+ minutes = (total_seconds % 3600) / 60
32
+ seconds = total_seconds % 60
33
+
34
+ parts = []
35
+ parts << "#{hours}h" if hours.positive?
36
+ parts << "#{minutes}m" if minutes.positive? || hours.positive?
37
+ parts << "#{seconds}s"
38
+ parts.join(" ")
39
+ end
40
+ end
41
+ end
@@ -6,22 +6,70 @@ module Raif
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  class_methods do
9
- def json_schema_definition(schema_name, &block)
9
+ def json_schema_definition(schema_name, dynamic: false, &block)
10
10
  raise ArgumentError, "A block must be provided to define the JSON schema" unless block_given?
11
11
 
12
- @schemas ||= {}
13
- @schemas[schema_name] = Raif::JsonSchemaBuilder.new
14
- @schemas[schema_name].instance_eval(&block)
15
- @schemas[schema_name]
12
+ # Check if block expects an instance parameter (arity == 1)
13
+ # arity == 0: no parameters (class-level schema)
14
+ # arity == 1: one parameter (instance-dependent schema)
15
+ if block.arity == 1
16
+ # Store block for instance-dependent schema building
17
+ @schema_blocks ||= {}
18
+ @schema_blocks[schema_name] = block
19
+ elsif dynamic
20
+ # Store block for class-level dynamic schema (re-evaluated each call)
21
+ @dynamic_schema_blocks ||= {}
22
+ @dynamic_schema_blocks[schema_name] = block
23
+ else
24
+ # Build schema immediately for class-level (backward compatible)
25
+ @schemas ||= {}
26
+ @schemas[schema_name] = Raif::JsonSchemaBuilder.new
27
+ @schemas[schema_name].instance_eval(&block)
28
+ end
16
29
  end
17
30
 
18
31
  def schema_defined?(schema_name)
19
- @schemas&.dig(schema_name).present?
32
+ @schemas&.dig(schema_name).present? ||
33
+ @schema_blocks&.dig(schema_name).present? ||
34
+ @dynamic_schema_blocks&.dig(schema_name).present?
20
35
  end
21
36
 
22
37
  def schema_for(schema_name)
38
+ # Check if this is an instance-dependent schema
39
+ if @schema_blocks&.dig(schema_name).present?
40
+ raise Raif::Errors::InstanceDependentSchemaError,
41
+ "The schema '#{schema_name}' is instance-dependent and cannot be accessed at the class level. " \
42
+ "Call this method on an instance instead."
43
+ end
44
+
45
+ # Check if this is a dynamic schema (re-evaluate each call)
46
+ if @dynamic_schema_blocks&.dig(schema_name).present?
47
+ builder = Raif::JsonSchemaBuilder.new
48
+ builder.instance_eval(&@dynamic_schema_blocks[schema_name])
49
+ return builder.to_schema
50
+ end
51
+
23
52
  @schemas[schema_name].to_schema
24
53
  end
54
+
55
+ def instance_dependent_schema?(schema_name)
56
+ @schema_blocks&.dig(schema_name).present?
57
+ end
58
+ end
59
+
60
+ # Instance method to build schema with instance context
61
+ def schema_for_instance(schema_name)
62
+ block = self.class.instance_variable_get(:@schema_blocks)&.[](schema_name)
63
+
64
+ if block
65
+ # Build schema with instance context
66
+ builder = Raif::JsonSchemaBuilder.new
67
+ builder.build_with_instance(self, &block)
68
+ builder.to_schema
69
+ elsif self.class.schema_defined?(schema_name)
70
+ # Fall back to class-level schema (handles both static and dynamic)
71
+ self.class.schema_for(schema_name)
72
+ end
25
73
  end
26
74
  end
27
75
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::LlmPromptCaching
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ class_attribute :anthropic_prompt_caching_enabled, instance_writer: false, default: false
8
+ class_attribute :bedrock_prompt_caching_enabled, instance_writer: false, default: false
9
+ end
10
+
11
+ class_methods do
12
+ def enable_anthropic_prompt_caching
13
+ self.anthropic_prompt_caching_enabled = true
14
+ end
15
+
16
+ def enable_bedrock_prompt_caching
17
+ self.bedrock_prompt_caching_enabled = true
18
+ end
19
+ end
20
+ end
@@ -3,6 +3,12 @@
3
3
  module Raif::Concerns::Llms::Anthropic::MessageFormatting
4
4
  extend ActiveSupport::Concern
5
5
 
6
+ def format_messages(messages)
7
+ # Anthropic tool results come back as user-role content blocks, so conversation
8
+ # continuations may need adjacent user messages collapsed after formatting.
9
+ consolidate_consecutive_role_messages(super, content_key: "content")
10
+ end
11
+
6
12
  def format_model_image_input_message(image_input)
7
13
  if image_input.source_type == :url
8
14
  {
@@ -48,4 +54,32 @@ module Raif::Concerns::Llms::Anthropic::MessageFormatting
48
54
  raise Raif::Errors::InvalidModelFileInputError, "Invalid model file input source type: #{file_input.source_type}"
49
55
  end
50
56
  end
57
+
58
+ def format_tool_call_message(tool_call)
59
+ content_array = []
60
+ content_array << format_string_message(tool_call["assistant_message"]) if tool_call["assistant_message"].present?
61
+
62
+ content_array << {
63
+ "type" => "tool_use",
64
+ "id" => tool_call["provider_tool_call_id"],
65
+ "name" => tool_call["name"],
66
+ "input" => tool_call["arguments"]
67
+ }
68
+
69
+ {
70
+ "role" => "assistant",
71
+ "content" => content_array
72
+ }
73
+ end
74
+
75
+ def format_tool_call_result_message(tool_call_result)
76
+ {
77
+ "role" => "user",
78
+ "content" => [{
79
+ "type" => "tool_result",
80
+ "tool_use_id" => tool_call_result["provider_tool_call_id"],
81
+ "content" => tool_call_result["result"].is_a?(String) ? tool_call_result["result"] : JSON.generate(tool_call_result["result"])
82
+ }]
83
+ }
84
+ end
51
85
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::Llms::Anthropic::ResponseToolCalls
4
+ extend ActiveSupport::Concern
5
+
6
+ def extract_response_tool_calls(resp)
7
+ return if resp&.dig("content").nil?
8
+
9
+ # Find any tool_use content blocks
10
+ tool_uses = resp&.dig("content")&.select do |content|
11
+ content["type"] == "tool_use"
12
+ end
13
+
14
+ return if tool_uses.blank?
15
+
16
+ tool_uses.map do |tool_use|
17
+ {
18
+ "provider_tool_call_id" => tool_use["id"],
19
+ "name" => tool_use["name"],
20
+ "arguments" => tool_use["input"],
21
+ }
22
+ end
23
+ end
24
+ end
@@ -53,4 +53,12 @@ module Raif::Concerns::Llms::Anthropic::ToolFormatting
53
53
  "Invalid provider-managed tool: #{tool.name} for #{key}"
54
54
  end
55
55
  end
56
+
57
+ def build_forced_tool_choice(tool_name)
58
+ { "type" => "tool", "name" => tool_name, "disable_parallel_tool_use" => true }
59
+ end
60
+
61
+ def build_required_tool_choice
62
+ { "type" => "any", "disable_parallel_tool_use" => true }
63
+ end
56
64
  end
@@ -3,6 +3,13 @@
3
3
  module Raif::Concerns::Llms::Bedrock::MessageFormatting
4
4
  extend ActiveSupport::Concern
5
5
 
6
+ def format_messages(messages)
7
+ # Bedrock tool results are represented as user-role content blocks, so a
8
+ # tool_result followed by the next user prompt must be merged into one user
9
+ # message before sending it to the provider.
10
+ consolidate_consecutive_role_messages(super, content_key: "content")
11
+ end
12
+
6
13
  def format_string_message(content, role: nil)
7
14
  { "text" => content }
8
15
  end
@@ -67,4 +74,40 @@ module Raif::Concerns::Llms::Bedrock::MessageFormatting
67
74
  "text/markdown" => "md"
68
75
  }[content_type]
69
76
  end
77
+
78
+ def format_tool_call_message(tool_call)
79
+ content_array = []
80
+ content_array << format_string_message(tool_call["assistant_message"]) if tool_call["assistant_message"].present?
81
+
82
+ content_array << {
83
+ "tool_use" => {
84
+ "tool_use_id" => tool_call["provider_tool_call_id"],
85
+ "name" => tool_call["name"],
86
+ "input" => tool_call["arguments"]
87
+ }
88
+ }
89
+
90
+ {
91
+ "role" => "assistant",
92
+ "content" => content_array
93
+ }
94
+ end
95
+
96
+ def format_tool_call_result_message(tool_call_result)
97
+ tool_result_content = if tool_call_result["result"].is_a?(String)
98
+ { "text" => tool_call_result["result"] }
99
+ else
100
+ { "json" => tool_call_result["result"] }
101
+ end
102
+
103
+ {
104
+ "role" => "user",
105
+ "content" => [{
106
+ "tool_result" => {
107
+ "tool_use_id" => tool_call_result["provider_tool_call_id"],
108
+ "content" => [tool_result_content]
109
+ }
110
+ }]
111
+ }
112
+ end
70
113
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::Llms::Bedrock::ResponseToolCalls
4
+ extend ActiveSupport::Concern
5
+
6
+ def extract_response_tool_calls(resp)
7
+ # Get the message from the response object
8
+ message = resp.output.message
9
+ return if message.content.nil?
10
+
11
+ # Find any tool_use blocks in the content array
12
+ tool_uses = message.content.select do |content|
13
+ content.respond_to?(:tool_use) && content.tool_use.present?
14
+ end
15
+
16
+ return if tool_uses.blank?
17
+
18
+ tool_uses.map do |content|
19
+ {
20
+ "provider_tool_call_id" => content.tool_use.tool_use_id,
21
+ "name" => content.tool_use.name,
22
+ "arguments" => content.tool_use.input
23
+ }
24
+ end
25
+ end
26
+ end
@@ -34,4 +34,12 @@ module Raif::Concerns::Llms::Bedrock::ToolFormatting
34
34
  tools: tools.map{|tool| { tool_spec: tool } }
35
35
  }
36
36
  end
37
+
38
+ def build_forced_tool_choice(tool_name)
39
+ { tool: { name: tool_name } }
40
+ end
41
+
42
+ def build_required_tool_choice
43
+ { any: {} }
44
+ end
37
45
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::Llms::Google::MessageFormatting
4
+ extend ActiveSupport::Concern
5
+
6
+ # Google uses a different envelope ("parts") and also represents tool results as
7
+ # user-role messages, so we normalize adjacent same-role messages after formatting.
8
+ def format_messages(messages)
9
+ formatted_messages = messages.map do |message|
10
+ if message.is_a?(Hash) && message["type"] == "tool_call"
11
+ format_tool_call_message(message)
12
+ elsif message.is_a?(Hash) && message["type"] == "tool_call_result"
13
+ format_tool_call_result_message(message)
14
+ else
15
+ role = message["role"] || message[:role]
16
+ # Google uses "model" instead of "assistant"
17
+ google_role = role == "assistant" ? "model" : role
18
+ {
19
+ "role" => google_role,
20
+ "parts" => format_message_content(message["content"] || message[:content], role: role)
21
+ }
22
+ end
23
+ end
24
+
25
+ consolidate_consecutive_role_messages(formatted_messages, content_key: "parts")
26
+ end
27
+
28
+ def format_string_message(content, role: nil)
29
+ { "text" => content }
30
+ end
31
+
32
+ def format_model_image_input_message(image_input)
33
+ if image_input.source_type == :url
34
+ {
35
+ "fileData" => {
36
+ "mimeType" => image_input.content_type,
37
+ "fileUri" => image_input.url
38
+ }
39
+ }
40
+ elsif image_input.source_type == :file_content
41
+ {
42
+ "inlineData" => {
43
+ "mimeType" => image_input.content_type,
44
+ "data" => image_input.base64_data
45
+ }
46
+ }
47
+ else
48
+ raise Raif::Errors::InvalidModelImageInputError, "Invalid model image input source type: #{image_input.source_type}"
49
+ end
50
+ end
51
+
52
+ def format_model_file_input_message(file_input)
53
+ if file_input.source_type == :url
54
+ {
55
+ "fileData" => {
56
+ "mimeType" => file_input.content_type,
57
+ "fileUri" => file_input.url
58
+ }
59
+ }
60
+ elsif file_input.source_type == :file_content
61
+ {
62
+ "inlineData" => {
63
+ "mimeType" => file_input.content_type,
64
+ "data" => file_input.base64_data
65
+ }
66
+ }
67
+ else
68
+ raise Raif::Errors::InvalidModelFileInputError, "Invalid model file input source type: #{file_input.source_type}"
69
+ end
70
+ end
71
+
72
+ def format_tool_call_message(tool_call)
73
+ parts = []
74
+
75
+ if tool_call["assistant_message"].present?
76
+ parts << format_string_message(tool_call["assistant_message"])
77
+ end
78
+
79
+ function_call_part = {
80
+ "functionCall" => {
81
+ "name" => tool_call["name"],
82
+ "args" => tool_call["arguments"]
83
+ }
84
+ }
85
+
86
+ # Include thoughtSignature if present (required for Gemini 2.5+ thinking models)
87
+ thought_signature = tool_call.dig("provider_metadata", "thought_signature")
88
+ function_call_part["thoughtSignature"] = thought_signature if thought_signature.present?
89
+
90
+ parts << function_call_part
91
+
92
+ {
93
+ "role" => "model",
94
+ "parts" => parts
95
+ }
96
+ end
97
+
98
+ def format_tool_call_result_message(tool_call_result)
99
+ result = tool_call_result["result"]
100
+ response_content = result.is_a?(String) ? { "output" => result } : result
101
+
102
+ {
103
+ "role" => "user",
104
+ "parts" => [{
105
+ "functionResponse" => {
106
+ "name" => tool_call_result["name"],
107
+ "response" => response_content
108
+ }
109
+ }]
110
+ }
111
+ end
112
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::Llms::Google::ResponseToolCalls
4
+ extend ActiveSupport::Concern
5
+
6
+ def extract_response_tool_calls(resp)
7
+ parts = resp&.dig("candidates", 0, "content", "parts")
8
+ return if parts.blank?
9
+
10
+ # Find any functionCall parts
11
+ function_calls = parts.select { |part| part.key?("functionCall") }
12
+
13
+ return if function_calls.blank?
14
+
15
+ function_calls.map do |part|
16
+ function_call = part["functionCall"]
17
+ tool_call = {
18
+ # Google doesn't provide a unique ID for function calls, so we generate one
19
+ "provider_tool_call_id" => SecureRandom.uuid,
20
+ "name" => function_call["name"],
21
+ "arguments" => function_call["args"]
22
+ }
23
+
24
+ # Capture thoughtSignature if present (required for Gemini 2.5+ thinking models)
25
+ if part["thoughtSignature"].present?
26
+ tool_call["provider_metadata"] = { "thought_signature" => part["thoughtSignature"] }
27
+ end
28
+
29
+ tool_call
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,76 @@
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
+ def build_required_tool_choice
53
+ { mode: "ANY" }
54
+ end
55
+
56
+ private
57
+
58
+ # Google's API doesn't support additionalProperties in JSON schemas
59
+ # This method recursively removes it from the schema
60
+ def sanitize_schema_for_google(schema)
61
+ return schema unless schema.is_a?(Hash)
62
+
63
+ sanitized = schema.except(:additionalProperties, "additionalProperties")
64
+
65
+ sanitized.transform_values do |value|
66
+ case value
67
+ when Hash
68
+ sanitize_schema_for_google(value)
69
+ when Array
70
+ value.map { |item| sanitize_schema_for_google(item) }
71
+ else
72
+ value
73
+ end
74
+ end
75
+ end
76
+ 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
 
@@ -39,4 +45,34 @@ module Raif::Concerns::Llms::MessageFormatting
39
45
  { "type" => "text", "text" => content }
40
46
  end
41
47
 
48
+ def consolidate_consecutive_role_messages(messages, content_key:)
49
+ # Bedrock, Anthropic, and Google all model tool results as normal role-based
50
+ # message content blocks. After formatting, a tool result can therefore be a
51
+ # "user" message immediately followed by the next user turn. Those providers
52
+ # expect alternating roles, so their adapters collapse adjacent same-role blocks.
53
+ return messages if messages.size <= 1
54
+
55
+ messages.each_with_object([]) do |message, consolidated|
56
+ candidate = message.deep_dup
57
+ previous_message = consolidated.last
58
+
59
+ if mergeable_consecutive_role_messages?(previous_message, candidate, content_key:)
60
+ previous_message[content_key] += candidate[content_key]
61
+ else
62
+ consolidated << candidate
63
+ end
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def mergeable_consecutive_role_messages?(previous_message, message, content_key:)
70
+ previous_message.is_a?(Hash) &&
71
+ message.is_a?(Hash) &&
72
+ previous_message["role"].present? &&
73
+ previous_message["role"] == message["role"] &&
74
+ previous_message[content_key].is_a?(Array) &&
75
+ message[content_key].is_a?(Array)
76
+ end
77
+
42
78
  end
@@ -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,12 @@ 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
30
+
31
+ def build_required_tool_choice
32
+ "required"
33
+ end
26
34
  end