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
@@ -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,12 @@ 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
46
+
47
+ def build_required_tool_choice
48
+ "required"
49
+ end
42
50
  end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::ProviderManagedToolCalls
4
+ extend ActiveSupport::Concern
5
+
6
+ # Provider-managed tool data is not normalized by the provider SDKs the same
7
+ # way developer-managed tool calls are. This method smooths those differences
8
+ # into one admin-friendly structure for the model completion page.
9
+ def provider_managed_tool_calls
10
+ # Memoized for repeated reads during a request/render. This assumes the
11
+ # completion's response payload is not mutated after first access.
12
+ @provider_managed_tool_calls ||= begin
13
+ tool_calls = extract_provider_managed_tool_calls
14
+ tool_calls = inferred_provider_managed_tool_calls if tool_calls.empty?
15
+
16
+ tool_calls.map do |tool_call|
17
+ next tool_call unless tool_call["tool_name"] == "web_search"
18
+
19
+ # Search sources can come from explicit provider result blocks
20
+ # (Anthropic) or from top-level citations (OpenAI / Google), so we
21
+ # merge both.
22
+ tool_call.merge("sources" => merge_provider_managed_sources(tool_call["sources"], citations))
23
+ end
24
+ end
25
+ end
26
+
27
+ # Returns citations with URLs sanitized to only allow http/https schemes.
28
+ def sanitized_citations
29
+ @sanitized_citations ||= Array(citations).map do |citation|
30
+ url = citation["url"]
31
+ safe_url = url.present? && url.match?(%r{\Ahttps?://}i) ? url : nil
32
+ citation.merge("url" => safe_url)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def extract_provider_managed_tool_calls
39
+ response_blocks = Array(response_array).select { |block| block.is_a?(Hash) }
40
+ result_blocks_by_tool_use_id = response_blocks.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |block, hash|
41
+ next if block["tool_use_id"].blank?
42
+
43
+ hash[block["tool_use_id"]] << block
44
+ end
45
+
46
+ response_blocks.filter_map do |block|
47
+ case block["type"]
48
+ when "server_tool_use"
49
+ # Anthropic stores the tool invocation in one block and the result in a
50
+ # separate block keyed by `tool_use_id`.
51
+ build_provider_managed_server_tool_call(block, result_blocks_by_tool_use_id)
52
+ when "web_search_call", "web_search_preview",
53
+ "code_interpreter_call", "code_interpreter",
54
+ "image_generation_call", "image_generation"
55
+ # OpenAI Responses persists provider-managed calls as top-level typed
56
+ # blocks like `web_search_call`, `code_interpreter`, etc.
57
+ build_provider_managed_tool_call_from_type(block)
58
+ end
59
+ end
60
+ end
61
+
62
+ def build_provider_managed_server_tool_call(block, result_blocks_by_tool_use_id)
63
+ tool_name = normalize_provider_managed_tool_name(block["name"])
64
+ return unless provider_managed_tool_available?(tool_name)
65
+
66
+ raw_result = result_blocks_by_tool_use_id[block["id"]].presence
67
+ {
68
+ "tool_name" => tool_name,
69
+ "provider_tool_call_id" => block["id"],
70
+ "status" => block["status"],
71
+ "arguments" => block["input"].presence,
72
+ "sources" => extract_provider_managed_sources(raw_result),
73
+ "raw_result" => raw_result,
74
+ "inferred" => false
75
+ }
76
+ end
77
+
78
+ def build_provider_managed_tool_call_from_type(block)
79
+ tool_name = normalize_provider_managed_tool_name(block["type"])
80
+ return unless provider_managed_tool_available?(tool_name)
81
+
82
+ payload = block.except("id", "type", "status").presence
83
+ {
84
+ "tool_name" => tool_name,
85
+ "provider_tool_call_id" => block["id"],
86
+ "status" => block["status"],
87
+ "arguments" => payload,
88
+ "sources" => [],
89
+ "raw_result" => payload,
90
+ "inferred" => false
91
+ }
92
+ end
93
+
94
+ def inferred_provider_managed_tool_calls
95
+ # Google currently gives us citations for provider-managed web search, but
96
+ # not a first-class tool call block in `response_array`, so we infer a
97
+ # single search invocation when web search was available and citations exist.
98
+ return [] unless provider_managed_tool_available?("web_search") && citations.present?
99
+
100
+ [{
101
+ "tool_name" => "web_search",
102
+ "provider_tool_call_id" => nil,
103
+ "status" => "completed",
104
+ "arguments" => nil,
105
+ "sources" => merge_provider_managed_sources([], citations),
106
+ "raw_result" => nil,
107
+ "inferred" => true
108
+ }]
109
+ end
110
+
111
+ def extract_provider_managed_sources(result_blocks)
112
+ Array(result_blocks).flat_map do |result_block|
113
+ Array(result_block["content"]).filter_map do |content_block|
114
+ next unless content_block.is_a?(Hash) && content_block["type"] == "web_search_result"
115
+
116
+ {
117
+ "title" => content_block["title"],
118
+ "url" => normalize_provider_managed_source_url(content_block["url"]),
119
+ "page_age" => content_block["page_age"]
120
+ }.compact
121
+ end
122
+ end.uniq { |source| source["url"].presence || source["title"] }
123
+ end
124
+
125
+ def merge_provider_managed_sources(existing_sources, extra_sources)
126
+ (Array(existing_sources) + Array(extra_sources)).filter_map do |source|
127
+ next unless source.is_a?(Hash)
128
+
129
+ {
130
+ "title" => source["title"],
131
+ "url" => normalize_provider_managed_source_url(source["url"]),
132
+ "page_age" => source["page_age"]
133
+ }.compact.presence
134
+ end.uniq { |source| source["url"].presence || source["title"] }
135
+ end
136
+
137
+ def normalize_provider_managed_tool_name(name)
138
+ case name.to_s
139
+ when "web_search", "web_search_call", "web_search_preview"
140
+ "web_search"
141
+ when "code_execution", "code_interpreter", "code_interpreter_call"
142
+ "code_execution"
143
+ when "image_generation", "image_generation_call"
144
+ "image_generation"
145
+ end
146
+ end
147
+
148
+ def provider_managed_tool_available?(tool_name)
149
+ return false if tool_name.blank?
150
+
151
+ available_model_tools_map[tool_name]&.provider_managed?
152
+ end
153
+
154
+ def normalize_provider_managed_source_url(url)
155
+ return if url.blank?
156
+
157
+ url = Raif::Utils::HtmlFragmentProcessor.strip_tracking_parameters(url)
158
+ return unless url.match?(%r{\Ahttps?://}i)
159
+
160
+ url
161
+ end
162
+ 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,12 +1,64 @@
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
32
+ prepend Raif::Concerns::HasPromptTemplates
33
+
4
34
  include Raif::Concerns::HasLlm
5
35
  include Raif::Concerns::HasRequestedLanguage
6
36
  include Raif::Concerns::HasAvailableModelTools
7
37
  include Raif::Concerns::LlmResponseParsing
38
+ include Raif::Concerns::LlmPromptCaching
8
39
 
9
40
  belongs_to :creator, polymorphic: true
41
+ belongs_to :source, polymorphic: true, optional: true
42
+
43
+ class << self
44
+ def before_prompt_model_for_entry_response(&block)
45
+ @before_prompt_model_for_entry_response_blocks ||= []
46
+ @before_prompt_model_for_entry_response_blocks << block if block
47
+ end
48
+
49
+ def before_prompt_model_for_entry_response_blocks
50
+ blocks = []
51
+
52
+ # Collect blocks from ancestors (in reverse order so parent blocks run first)
53
+ ancestors.reverse_each do |klass|
54
+ if klass.instance_variable_defined?(:@before_prompt_model_for_entry_response_blocks)
55
+ blocks.concat(klass.instance_variable_get(:@before_prompt_model_for_entry_response_blocks))
56
+ end
57
+ end
58
+
59
+ blocks
60
+ end
61
+ end
10
62
 
11
63
  has_many :entries, class_name: "Raif::ConversationEntry", dependent: :destroy, foreign_key: :raif_conversation_id, inverse_of: :raif_conversation
12
64
 
@@ -14,6 +66,7 @@ class Raif::Conversation < Raif::ApplicationRecord
14
66
 
15
67
  after_initialize -> { self.available_model_tools ||= [] }
16
68
  after_initialize -> { self.available_user_tools ||= [] }
69
+ after_initialize -> { self.llm_messages_max_length ||= Raif.config.conversation_llm_messages_max_length_default }
17
70
 
18
71
  before_validation ->{ self.type ||= "Raif::Conversation" }, on: :create
19
72
 
@@ -39,17 +92,33 @@ class Raif::Conversation < Raif::ApplicationRecord
39
92
  end
40
93
 
41
94
  def prompt_model_for_entry_response(entry:, &block)
42
- update(system_prompt: build_system_prompt)
95
+ self.class.before_prompt_model_for_entry_response_blocks.each do |callback_block|
96
+ instance_exec(entry, &callback_block)
97
+ end
98
+
99
+ self.system_prompt = build_system_prompt
100
+ self.generating_entry_response = true
101
+ save!
43
102
 
44
- llm.chat(
103
+ model_completion = llm.chat(
45
104
  messages: llm_messages,
46
105
  source: entry,
47
106
  response_format: response_format.to_sym,
48
107
  system_prompt: system_prompt,
49
108
  available_model_tools: available_model_tools,
109
+ anthropic_prompt_caching_enabled: self.class.anthropic_prompt_caching_enabled,
110
+ bedrock_prompt_caching_enabled: self.class.bedrock_prompt_caching_enabled,
50
111
  &block
51
112
  )
113
+
114
+ self.generating_entry_response = false
115
+ save!
116
+
117
+ model_completion
52
118
  rescue StandardError => e
119
+ self.generating_entry_response = false
120
+ save!
121
+
53
122
  Rails.logger.error("Error processing conversation entry ##{entry.id}. #{e.message}")
54
123
  Rails.logger.error(e.backtrace.join("\n"))
55
124
 
@@ -75,14 +144,36 @@ class Raif::Conversation < Raif::ApplicationRecord
75
144
  def llm_messages
76
145
  messages = []
77
146
 
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?
147
+ # Apply max length limit to entries if configured (nil means no limit)
148
+ included_entries = entries.oldest_first.includes(:raif_model_tool_invocations)
149
+ included_entries = included_entries.last(llm_messages_max_length) if llm_messages_max_length.present?
150
+
151
+ included_entries.each do |entry|
152
+ unless entry.user_message.blank?
153
+ messages << Raif::Messages::UserMessage.new(content: entry.user_message).to_h
154
+ end
155
+
80
156
  next unless entry.completed?
81
157
 
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?
158
+ tool_invocations = entry.raif_model_tool_invocations.to_a
159
+
160
+ if tool_invocations.any?
161
+ # First tool call includes the assistant's message (if any).
162
+ # For the result payload we send the model-facing observation when the tool
163
+ # opts into observations, while keeping the raw invocation.result persisted
164
+ # for admin/UI rendering.
165
+ first_invocation = tool_invocations.shift
166
+ messages << first_invocation.as_tool_call_message(assistant_message: entry.model_response_message.presence)
167
+ messages << first_invocation.as_tool_call_result_message(result: tool_result_for_llm(first_invocation))
168
+
169
+ # Remaining tool calls (if multiple)
170
+ tool_invocations.each do |tool_invocation|
171
+ messages << tool_invocation.as_tool_call_message
172
+ messages << tool_invocation.as_tool_call_result_message(result: tool_result_for_llm(tool_invocation))
173
+ end
174
+ elsif entry.model_response_message.present?
175
+ # No tool calls, just a regular assistant response
176
+ messages << Raif::Messages::AssistantMessage.new(content: entry.model_response_message).to_h
86
177
  end
87
178
  end
88
179
 
@@ -93,4 +184,17 @@ class Raif::Conversation < Raif::ApplicationRecord
93
184
  available_user_tools.map(&:constantize)
94
185
  end
95
186
 
187
+ private
188
+
189
+ def tool_result_for_llm(tool_invocation)
190
+ # Some tools persist a compact structured result for display/admin purposes but
191
+ # need to send richer text/XML back to the model for the continuation turn.
192
+ return tool_invocation.result unless tool_invocation.triggers_observation_to_model?
193
+
194
+ tool = tool_invocation.tool
195
+ return tool_invocation.result unless tool.respond_to?(:observation_for_invocation)
196
+
197
+ tool.observation_for_invocation(tool_invocation).presence || tool_invocation.result
198
+ end
199
+
96
200
  end
@@ -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
@@ -32,8 +59,7 @@ class Raif::ConversationEntry < Raif::ApplicationRecord
32
59
  def add_user_tool_invocation_to_user_message
33
60
  return unless raif_user_tool_invocation.present?
34
61
 
35
- separator = response_format == "html" ? "<br>" : "\n\n"
36
- self.user_message = [user_message, raif_user_tool_invocation.as_user_message].join(separator)
62
+ self.user_message = [user_message, raif_user_tool_invocation.as_user_message].join("\n\n")
37
63
  end
38
64
 
39
65
  def response_format
@@ -47,7 +73,7 @@ class Raif::ConversationEntry < Raif::ApplicationRecord
47
73
  def process_entry!
48
74
  self.model_response_message = ""
49
75
 
50
- self.raif_model_completion = raif_conversation.prompt_model_for_entry_response(entry: self) do |model_completion, _delta, _sse_event|
76
+ model_completion = raif_conversation.prompt_model_for_entry_response(entry: self) do |model_completion, _delta, _sse_event|
51
77
  self.raw_response = model_completion.raw_response
52
78
  self.model_response_message = raif_conversation.process_model_response_message(
53
79
  message: model_completion.parsed_response(force_reparse: true),
@@ -63,6 +89,10 @@ class Raif::ConversationEntry < Raif::ApplicationRecord
63
89
  broadcast_replace_to raif_conversation
64
90
  end
65
91
 
92
+ # Failed prompt attempts can still persist a model completion for debugging.
93
+ # Avoid clearing the has_one association with nil, which would delete that row.
94
+ self.raif_model_completion = model_completion if model_completion.present?
95
+
66
96
  if raif_model_completion.present? && (raif_model_completion.parsed_response.present? || raif_model_completion.response_tool_calls.present?)
67
97
  extract_message_and_invoke_tools!
68
98
  create_entry_for_observation! if triggers_observation_to_model?
@@ -98,7 +128,11 @@ private
98
128
  tool_klass = available_model_tools_map[tool_call["name"]]
99
129
  next if tool_klass.nil?
100
130
 
101
- tool_klass.invoke_tool(tool_arguments: tool_call["arguments"], source: self)
131
+ tool_klass.invoke_tool(
132
+ provider_tool_call_id: tool_call["provider_tool_call_id"],
133
+ tool_arguments: tool_call["arguments"],
134
+ source: self
135
+ )
102
136
  end
103
137
 
104
138
  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)
@@ -29,6 +29,15 @@ private
29
29
  end
30
30
 
31
31
  def bedrock_client
32
- @bedrock_client ||= Aws::BedrockRuntime::Client.new(region: Raif.config.aws_bedrock_region)
32
+ @bedrock_client ||= begin
33
+ client_options = {
34
+ region: Raif.config.aws_bedrock_region
35
+ }
36
+
37
+ client_options[:http_read_timeout] = Raif.config.request_read_timeout if Raif.config.request_read_timeout
38
+ client_options[:http_open_timeout] = Raif.config.request_open_timeout if Raif.config.request_open_timeout
39
+
40
+ Aws::BedrockRuntime::Client.new(client_options)
41
+ end
33
42
  end
34
43
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Raif::EmbeddingModels::Google < Raif::EmbeddingModel
4
+ def generate_embedding!(input, dimensions: nil)
5
+ unless input.is_a?(String)
6
+ raise ArgumentError, "Raif::EmbeddingModels::Google#generate_embedding! input must be a string"
7
+ end
8
+
9
+ response = connection.post("models/#{api_name}:embedContent") do |req|
10
+ req.body = build_request_parameters(input, dimensions:)
11
+ end
12
+
13
+ response.body.dig("embedding", "values")
14
+ end
15
+
16
+ private
17
+
18
+ def build_request_parameters(input, dimensions: nil)
19
+ params = {
20
+ content: {
21
+ parts: [{ text: input }]
22
+ }
23
+ }
24
+
25
+ params[:outputDimensionality] = dimensions if dimensions.present?
26
+ params
27
+ end
28
+
29
+ def connection
30
+ @connection ||= Faraday.new(url: "https://generativelanguage.googleapis.com/v1beta", request: Raif.default_request_options) do |f|
31
+ f.headers["x-goog-api-key"] = Raif.config.google_api_key
32
+ f.request :json
33
+ f.response :json
34
+ f.response :raise_error
35
+ end
36
+ end
37
+ end
@@ -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