raif 1.2.2 → 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 (167) 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/has_llm.rb +1 -1
  25. data/app/models/raif/concerns/json_schema_definition.rb +40 -5
  26. data/app/models/raif/concerns/llms/anthropic/message_formatting.rb +28 -0
  27. data/app/models/raif/concerns/llms/anthropic/response_tool_calls.rb +24 -0
  28. data/app/models/raif/concerns/llms/anthropic/tool_formatting.rb +4 -0
  29. data/app/models/raif/concerns/llms/bedrock/message_formatting.rb +36 -0
  30. data/app/models/raif/concerns/llms/bedrock/response_tool_calls.rb +26 -0
  31. data/app/models/raif/concerns/llms/bedrock/tool_formatting.rb +4 -0
  32. data/app/models/raif/concerns/llms/google/message_formatting.rb +109 -0
  33. data/app/models/raif/concerns/llms/google/response_tool_calls.rb +32 -0
  34. data/app/models/raif/concerns/llms/google/tool_formatting.rb +72 -0
  35. data/app/models/raif/concerns/llms/message_formatting.rb +11 -5
  36. data/app/models/raif/concerns/llms/open_ai/json_schema_validation.rb +3 -3
  37. data/app/models/raif/concerns/llms/open_ai_completions/message_formatting.rb +22 -0
  38. data/app/models/raif/concerns/llms/open_ai_completions/response_tool_calls.rb +22 -0
  39. data/app/models/raif/concerns/llms/open_ai_completions/tool_formatting.rb +4 -0
  40. data/app/models/raif/concerns/llms/open_ai_responses/message_formatting.rb +17 -0
  41. data/app/models/raif/concerns/llms/open_ai_responses/response_tool_calls.rb +26 -0
  42. data/app/models/raif/concerns/llms/open_ai_responses/tool_formatting.rb +4 -0
  43. data/app/models/raif/concerns/run_with.rb +127 -0
  44. data/app/models/raif/conversation.rb +96 -9
  45. data/app/models/raif/conversation_entry.rb +37 -8
  46. data/app/models/raif/embedding_model.rb +2 -1
  47. data/app/models/raif/embedding_models/open_ai.rb +1 -1
  48. data/app/models/raif/llm.rb +28 -3
  49. data/app/models/raif/llms/anthropic.rb +7 -19
  50. data/app/models/raif/llms/bedrock.rb +6 -20
  51. data/app/models/raif/llms/google.rb +140 -0
  52. data/app/models/raif/llms/open_ai_base.rb +19 -5
  53. data/app/models/raif/llms/open_ai_completions.rb +6 -11
  54. data/app/models/raif/llms/open_ai_responses.rb +6 -16
  55. data/app/models/raif/llms/open_router.rb +10 -14
  56. data/app/models/raif/model_completion.rb +61 -0
  57. data/app/models/raif/model_tool.rb +10 -2
  58. data/app/models/raif/model_tool_invocation.rb +38 -6
  59. data/app/models/raif/model_tools/agent_final_answer.rb +2 -7
  60. data/app/models/raif/model_tools/provider_managed/code_execution.rb +4 -0
  61. data/app/models/raif/model_tools/provider_managed/image_generation.rb +4 -0
  62. data/app/models/raif/model_tools/provider_managed/web_search.rb +4 -0
  63. data/app/models/raif/streaming_responses/google.rb +71 -0
  64. data/app/models/raif/task.rb +74 -18
  65. data/app/models/raif/user_tool_invocation.rb +19 -0
  66. data/app/views/layouts/raif/admin.html.erb +12 -1
  67. data/app/views/raif/admin/agents/_agent.html.erb +8 -0
  68. data/app/views/raif/admin/agents/_conversation_message.html.erb +28 -6
  69. data/app/views/raif/admin/agents/index.html.erb +2 -0
  70. data/app/views/raif/admin/agents/show.html.erb +46 -1
  71. data/app/views/raif/admin/configs/show.html.erb +117 -0
  72. data/app/views/raif/admin/conversations/_conversation_entry.html.erb +29 -34
  73. data/app/views/raif/admin/conversations/show.html.erb +2 -0
  74. data/app/views/raif/admin/model_completions/_model_completion.html.erb +9 -0
  75. data/app/views/raif/admin/model_completions/index.html.erb +26 -0
  76. data/app/views/raif/admin/model_completions/show.html.erb +124 -61
  77. data/app/views/raif/admin/model_tool_invocations/index.html.erb +22 -1
  78. data/app/views/raif/admin/model_tools/_list.html.erb +16 -0
  79. data/app/views/raif/admin/model_tools/_model_tool.html.erb +36 -0
  80. data/app/views/raif/admin/stats/_stats_tile.html.erb +34 -0
  81. data/app/views/raif/admin/stats/index.html.erb +71 -88
  82. data/app/views/raif/admin/stats/model_tool_invocations/index.html.erb +43 -0
  83. data/app/views/raif/admin/stats/tasks/index.html.erb +20 -6
  84. data/app/views/raif/admin/tasks/index.html.erb +6 -1
  85. data/app/views/raif/admin/tasks/show.html.erb +36 -3
  86. data/app/views/raif/conversation_entries/_form.html.erb +4 -1
  87. data/app/views/raif/conversations/_conversation.html.erb +10 -0
  88. data/app/views/raif/conversations/_entry_processed.turbo_stream.erb +12 -0
  89. data/app/views/raif/conversations/_full_conversation.html.erb +3 -6
  90. data/app/views/raif/conversations/_initial_chat_message.html.erb +5 -0
  91. data/app/views/raif/conversations/index.html.erb +23 -0
  92. data/config/locales/admin.en.yml +33 -1
  93. data/config/locales/en.yml +41 -4
  94. data/config/routes.rb +2 -0
  95. data/db/migrate/20250804013843_add_task_run_args_to_raif_tasks.rb +13 -0
  96. data/db/migrate/20250811171150_make_raif_task_creator_optional.rb +8 -0
  97. data/db/migrate/20250904194456_add_generating_entry_response_to_raif_conversations.rb +7 -0
  98. data/db/migrate/20250911125234_add_source_to_raif_tasks.rb +7 -0
  99. data/db/migrate/20251020005853_add_source_to_raif_agents.rb +7 -0
  100. data/db/migrate/20251020011346_rename_task_run_args_to_run_with.rb +7 -0
  101. data/db/migrate/20251020011405_add_run_with_to_raif_agents.rb +13 -0
  102. data/db/migrate/20251024160119_add_llm_messages_max_length_to_raif_conversations.rb +14 -0
  103. data/db/migrate/20251124185033_add_provider_tool_call_id_to_raif_model_tool_invocations.rb +7 -0
  104. data/db/migrate/20251128202941_add_tool_choice_to_raif_model_completions.rb +7 -0
  105. data/db/migrate/20260118144846_add_source_to_raif_conversations.rb +7 -0
  106. data/db/migrate/20260119000000_add_failure_tracking_to_raif_model_completions.rb +10 -0
  107. data/db/migrate/20260119000001_add_completed_at_to_raif_model_completions.rb +8 -0
  108. data/db/migrate/20260119000002_add_started_at_to_raif_model_completions.rb +8 -0
  109. data/exe/raif +7 -0
  110. data/lib/generators/raif/agent/agent_generator.rb +22 -7
  111. data/lib/generators/raif/agent/templates/agent.rb.tt +20 -24
  112. data/lib/generators/raif/agent/templates/agent_eval_set.rb.tt +48 -0
  113. data/lib/generators/raif/agent/templates/application_agent.rb.tt +1 -3
  114. data/lib/generators/raif/base_generator.rb +19 -0
  115. data/lib/generators/raif/conversation/conversation_generator.rb +21 -2
  116. data/lib/generators/raif/conversation/templates/application_conversation.rb.tt +0 -2
  117. data/lib/generators/raif/conversation/templates/conversation.rb.tt +34 -32
  118. data/lib/generators/raif/conversation/templates/conversation_eval_set.rb.tt +70 -0
  119. data/lib/generators/raif/eval_set/eval_set_generator.rb +28 -0
  120. data/lib/generators/raif/eval_set/templates/eval_set.rb.tt +21 -0
  121. data/lib/generators/raif/evals/setup/setup_generator.rb +47 -0
  122. data/lib/generators/raif/install/install_generator.rb +15 -0
  123. data/lib/generators/raif/install/templates/initializer.rb +89 -10
  124. data/lib/generators/raif/model_tool/model_tool_generator.rb +5 -5
  125. data/lib/generators/raif/model_tool/templates/model_tool.rb.tt +78 -78
  126. data/lib/generators/raif/model_tool/templates/model_tool_invocation_partial.html.erb.tt +1 -1
  127. data/lib/generators/raif/task/task_generator.rb +22 -3
  128. data/lib/generators/raif/task/templates/application_task.rb.tt +0 -2
  129. data/lib/generators/raif/task/templates/task.rb.tt +55 -59
  130. data/lib/generators/raif/task/templates/task_eval_set.rb.tt +54 -0
  131. data/lib/raif/cli/base.rb +39 -0
  132. data/lib/raif/cli/evals.rb +47 -0
  133. data/lib/raif/cli/evals_setup.rb +27 -0
  134. data/lib/raif/cli.rb +67 -0
  135. data/lib/raif/configuration.rb +57 -8
  136. data/lib/raif/engine.rb +8 -0
  137. data/lib/raif/errors/instance_dependent_schema_error.rb +8 -0
  138. data/lib/raif/errors/streaming_error.rb +6 -3
  139. data/lib/raif/errors.rb +1 -0
  140. data/lib/raif/evals/eval.rb +30 -0
  141. data/lib/raif/evals/eval_set.rb +111 -0
  142. data/lib/raif/evals/eval_sets/expectations.rb +53 -0
  143. data/lib/raif/evals/eval_sets/llm_judge_expectations.rb +255 -0
  144. data/lib/raif/evals/expectation_result.rb +39 -0
  145. data/lib/raif/evals/llm_judge.rb +32 -0
  146. data/lib/raif/evals/llm_judges/binary.rb +94 -0
  147. data/lib/raif/evals/llm_judges/comparative.rb +89 -0
  148. data/lib/raif/evals/llm_judges/scored.rb +63 -0
  149. data/lib/raif/evals/llm_judges/summarization.rb +166 -0
  150. data/lib/raif/evals/run.rb +202 -0
  151. data/lib/raif/evals/scoring_rubric.rb +174 -0
  152. data/lib/raif/evals.rb +26 -0
  153. data/lib/raif/json_schema_builder.rb +14 -0
  154. data/lib/raif/llm_registry.rb +218 -15
  155. data/lib/raif/messages.rb +180 -0
  156. data/lib/raif/migration_checker.rb +3 -3
  157. data/lib/raif/utils/colors.rb +23 -0
  158. data/lib/raif/utils.rb +1 -0
  159. data/lib/raif/version.rb +1 -1
  160. data/lib/raif.rb +13 -0
  161. data/lib/tasks/annotate_rb.rake +10 -0
  162. data/spec/support/current_temperature_test_tool.rb +34 -0
  163. data/spec/support/rspec_helpers.rb +8 -8
  164. data/spec/support/test_conversation.rb +1 -1
  165. metadata +77 -10
  166. data/app/models/raif/agents/re_act_agent.rb +0 -127
  167. data/app/models/raif/agents/re_act_step.rb +0 -33
@@ -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
@@ -77,7 +85,7 @@ module Raif
77
85
  temperature ||= default_temperature
78
86
  max_completion_tokens ||= default_max_completion_tokens
79
87
 
80
- model_completion = Raif::ModelCompletion.new(
88
+ model_completion = Raif::ModelCompletion.create!(
81
89
  messages: format_messages(messages),
82
90
  system_prompt: system_prompt,
83
91
  response_format: response_format,
@@ -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,
@@ -3,6 +3,7 @@
3
3
  class Raif::Llms::Anthropic < Raif::Llm
4
4
  include Raif::Concerns::Llms::Anthropic::MessageFormatting
5
5
  include Raif::Concerns::Llms::Anthropic::ToolFormatting
6
+ include Raif::Concerns::Llms::Anthropic::ResponseToolCalls
6
7
 
7
8
  def perform_model_completion!(model_completion, &block)
8
9
  params = build_request_parameters(model_completion)
@@ -21,7 +22,7 @@ class Raif::Llms::Anthropic < Raif::Llm
21
22
  private
22
23
 
23
24
  def connection
24
- @connection ||= Faraday.new(url: "https://api.anthropic.com/v1") do |f|
25
+ @connection ||= Faraday.new(url: "https://api.anthropic.com/v1", request: Raif.default_request_options) do |f|
25
26
  f.headers["x-api-key"] = Raif.config.anthropic_api_key
26
27
  f.headers["anthropic-version"] = "2023-06-01"
27
28
  f.request :json
@@ -64,6 +65,11 @@ private
64
65
  if supports_native_tool_use?
65
66
  tools = build_tools_parameter(model_completion)
66
67
  params[:tools] = tools unless tools.blank?
68
+
69
+ if model_completion.tool_choice.present?
70
+ tool_klass = model_completion.tool_choice.constantize
71
+ params[:tool_choice] = build_forced_tool_choice(tool_klass.tool_name)
72
+ end
67
73
  end
68
74
 
69
75
  params[:stream] = true if model_completion.stream_response?
@@ -92,24 +98,6 @@ private
92
98
  end
93
99
  end
94
100
 
95
- def extract_response_tool_calls(resp)
96
- return if resp&.dig("content").nil?
97
-
98
- # Find any tool_use content blocks
99
- tool_uses = resp&.dig("content")&.select do |content|
100
- content["type"] == "tool_use"
101
- end
102
-
103
- return if tool_uses.blank?
104
-
105
- tool_uses.map do |tool_use|
106
- {
107
- "name" => tool_use["name"],
108
- "arguments" => tool_use["input"]
109
- }
110
- end
111
- end
112
-
113
101
  def extract_citations(resp)
114
102
  return [] if resp&.dig("content").nil?
115
103
 
@@ -3,6 +3,7 @@
3
3
  class Raif::Llms::Bedrock < Raif::Llm
4
4
  include Raif::Concerns::Llms::Bedrock::MessageFormatting
5
5
  include Raif::Concerns::Llms::Bedrock::ToolFormatting
6
+ include Raif::Concerns::Llms::Bedrock::ResponseToolCalls
6
7
 
7
8
  def perform_model_completion!(model_completion, &block)
8
9
  if Raif.config.aws_bedrock_model_name_prefix.present?
@@ -72,6 +73,11 @@ private
72
73
  if supports_native_tool_use?
73
74
  tools = build_tools_parameter(model_completion)
74
75
  params[:tool_config] = tools unless tools.blank?
76
+
77
+ if model_completion.tool_choice.present?
78
+ tool_klass = model_completion.tool_choice.constantize
79
+ params[:tool_config][:tool_choice] = build_forced_tool_choice(tool_klass.tool_name)
80
+ end
75
81
  end
76
82
 
77
83
  params
@@ -121,26 +127,6 @@ private
121
127
  end
122
128
  end
123
129
 
124
- def extract_response_tool_calls(resp)
125
- # Get the message from the response object
126
- message = resp.output.message
127
- return if message.content.nil?
128
-
129
- # Find any tool_use blocks in the content array
130
- tool_uses = message.content.select do |content|
131
- content.respond_to?(:tool_use) && content.tool_use.present?
132
- end
133
-
134
- return if tool_uses.blank?
135
-
136
- tool_uses.map do |content|
137
- {
138
- "name" => content.tool_use.name,
139
- "arguments" => content.tool_use.input
140
- }
141
- end
142
- end
143
-
144
130
  def streaming_chunk_handler(model_completion, &block)
145
131
  return unless model_completion.stream_response?
146
132
 
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Raif::Llms::Google < Raif::Llm
4
+ include Raif::Concerns::Llms::Google::MessageFormatting
5
+ include Raif::Concerns::Llms::Google::ToolFormatting
6
+ include Raif::Concerns::Llms::Google::ResponseToolCalls
7
+
8
+ def perform_model_completion!(model_completion, &block)
9
+ params = build_request_parameters(model_completion)
10
+ endpoint = build_endpoint(model_completion)
11
+
12
+ response = connection.post(endpoint) do |req|
13
+ req.body = params
14
+ req.options.on_data = streaming_chunk_handler(model_completion, &block) if model_completion.stream_response?
15
+ end
16
+
17
+ unless model_completion.stream_response?
18
+ update_model_completion(model_completion, response.body)
19
+ end
20
+
21
+ model_completion
22
+ end
23
+
24
+ private
25
+
26
+ def connection
27
+ @connection ||= Faraday.new(url: "https://generativelanguage.googleapis.com/v1beta") do |f|
28
+ f.headers["x-goog-api-key"] = Raif.config.google_api_key
29
+ f.request :json
30
+ f.response :json
31
+ f.response :raise_error
32
+ end
33
+ end
34
+
35
+ def build_endpoint(model_completion)
36
+ if model_completion.stream_response?
37
+ "models/#{model_completion.model_api_name}:streamGenerateContent?alt=sse"
38
+ else
39
+ "models/#{model_completion.model_api_name}:generateContent"
40
+ end
41
+ end
42
+
43
+ def streaming_response_type
44
+ Raif::StreamingResponses::Google
45
+ end
46
+
47
+ def update_model_completion(model_completion, response_json)
48
+ model_completion.raw_response = if model_completion.response_format_json?
49
+ extract_json_response(response_json)
50
+ else
51
+ extract_text_response(response_json)
52
+ end
53
+
54
+ model_completion.response_array = response_json&.dig("candidates", 0, "content", "parts")
55
+ model_completion.response_tool_calls = extract_response_tool_calls(response_json)
56
+ model_completion.citations = extract_citations(response_json)
57
+ model_completion.completion_tokens = response_json&.dig("usageMetadata", "candidatesTokenCount")
58
+ model_completion.prompt_tokens = response_json&.dig("usageMetadata", "promptTokenCount")
59
+ model_completion.total_tokens = response_json&.dig("usageMetadata", "totalTokenCount") ||
60
+ (model_completion.completion_tokens.to_i + model_completion.prompt_tokens.to_i)
61
+ model_completion.save!
62
+ end
63
+
64
+ def build_request_parameters(model_completion)
65
+ params = {
66
+ contents: model_completion.messages
67
+ }
68
+
69
+ if model_completion.system_prompt.present?
70
+ params[:system_instruction] = { parts: [{ text: model_completion.system_prompt }] }
71
+ end
72
+
73
+ params[:generationConfig] = build_generation_config(model_completion)
74
+
75
+ if supports_native_tool_use?
76
+ tools = build_tools_parameter(model_completion)
77
+ params[:tools] = tools unless tools.blank?
78
+
79
+ if model_completion.tool_choice.present?
80
+ tool_klass = model_completion.tool_choice.constantize
81
+ params[:toolConfig] = { functionCallingConfig: build_forced_tool_choice(tool_klass.tool_name) }
82
+ end
83
+ end
84
+
85
+ params
86
+ end
87
+
88
+ def build_generation_config(model_completion)
89
+ config = {}
90
+
91
+ temperature = model_completion.temperature || default_temperature
92
+ config[:temperature] = temperature.to_f if temperature.present?
93
+
94
+ max_tokens = model_completion.max_completion_tokens || default_max_completion_tokens
95
+ config[:maxOutputTokens] = max_tokens if max_tokens.present?
96
+
97
+ # Use native JSON schema support for structured output
98
+ if model_completion.response_format_json? && model_completion.json_response_schema.present?
99
+ config[:responseMimeType] = "application/json"
100
+ config[:responseSchema] = sanitize_schema_for_google(model_completion.json_response_schema)
101
+ end
102
+
103
+ config
104
+ end
105
+
106
+ def extract_text_response(resp)
107
+ parts = resp&.dig("candidates", 0, "content", "parts")
108
+ return if parts.blank?
109
+
110
+ parts.select { |p| p.key?("text") }.map { |p| p["text"] }.join
111
+ end
112
+
113
+ def extract_json_response(resp)
114
+ # Google AI supports native JSON schema output, so the response should be in the text field
115
+ extract_text_response(resp)
116
+ end
117
+
118
+ def extract_citations(resp)
119
+ # Google AI returns grounding metadata for search results
120
+ grounding_metadata = resp&.dig("candidates", 0, "groundingMetadata")
121
+ return [] if grounding_metadata.blank?
122
+
123
+ citations = []
124
+
125
+ # Extract from grounding chunks
126
+ grounding_chunks = grounding_metadata["groundingChunks"] || []
127
+ grounding_chunks.each do |chunk|
128
+ web = chunk["web"]
129
+ next unless web.present?
130
+
131
+ citations << {
132
+ "url" => Raif::Utils::HtmlFragmentProcessor.strip_tracking_parameters(web["uri"]),
133
+ "title" => web["title"]
134
+ }
135
+ end
136
+
137
+ citations.uniq { |citation| citation["url"] }
138
+ end
139
+
140
+ end
@@ -28,11 +28,25 @@ class Raif::Llms::OpenAiBase < Raif::Llm
28
28
  private
29
29
 
30
30
  def connection
31
- @connection ||= Faraday.new(url: "https://api.openai.com/v1") do |f|
32
- f.headers["Authorization"] = "Bearer #{Raif.config.open_ai_api_key}"
33
- f.request :json
34
- f.response :json
35
- f.response :raise_error
31
+ @connection ||= begin
32
+ conn = Faraday.new(url: Raif.config.open_ai_base_url, request: Raif.default_request_options) do |f|
33
+ case Raif.config.open_ai_auth_header_style
34
+ when :bearer
35
+ f.headers["Authorization"] = "Bearer #{Raif.config.open_ai_api_key}"
36
+ when :api_key
37
+ f.headers["api-key"] = Raif.config.open_ai_api_key
38
+ else
39
+ raise Raif::Errors::InvalidConfigError,
40
+ "Raif.config.open_ai_auth_header_style must be either :bearer or :api_key"
41
+ end
42
+
43
+ f.request :json
44
+ f.response :json
45
+ f.response :raise_error
46
+ end
47
+
48
+ conn.params["api-version"] = Raif.config.open_ai_api_version if Raif.config.open_ai_api_version.present?
49
+ conn
36
50
  end
37
51
  end
38
52
 
@@ -3,6 +3,7 @@
3
3
  class Raif::Llms::OpenAiCompletions < Raif::Llms::OpenAiBase
4
4
  include Raif::Concerns::Llms::OpenAiCompletions::MessageFormatting
5
5
  include Raif::Concerns::Llms::OpenAiCompletions::ToolFormatting
6
+ include Raif::Concerns::Llms::OpenAiCompletions::ResponseToolCalls
6
7
 
7
8
  private
8
9
 
@@ -26,17 +27,6 @@ private
26
27
  )
27
28
  end
28
29
 
29
- def extract_response_tool_calls(resp)
30
- return if resp.dig("choices", 0, "message", "tool_calls").blank?
31
-
32
- resp.dig("choices", 0, "message", "tool_calls").map do |tool_call|
33
- {
34
- "name" => tool_call["function"]["name"],
35
- "arguments" => JSON.parse(tool_call["function"]["arguments"])
36
- }
37
- end
38
- end
39
-
40
30
  def build_request_parameters(model_completion)
41
31
  formatted_system_prompt = format_system_prompt(model_completion)
42
32
 
@@ -60,6 +50,11 @@ private
60
50
  if supports_native_tool_use?
61
51
  tools = build_tools_parameter(model_completion)
62
52
  parameters[:tools] = tools unless tools.blank?
53
+
54
+ if model_completion.tool_choice.present?
55
+ tool_klass = model_completion.tool_choice.constantize
56
+ parameters[:tool_choice] = build_forced_tool_choice(tool_klass.tool_name)
57
+ end
63
58
  end
64
59
 
65
60
  if model_completion.stream_response?
@@ -3,6 +3,7 @@
3
3
  class Raif::Llms::OpenAiResponses < Raif::Llms::OpenAiBase
4
4
  include Raif::Concerns::Llms::OpenAiResponses::MessageFormatting
5
5
  include Raif::Concerns::Llms::OpenAiResponses::ToolFormatting
6
+ include Raif::Concerns::Llms::OpenAiResponses::ResponseToolCalls
6
7
 
7
8
  private
8
9
 
@@ -27,22 +28,6 @@ private
27
28
  )
28
29
  end
29
30
 
30
- def extract_response_tool_calls(resp)
31
- return if resp["output"].blank?
32
-
33
- tool_calls = []
34
- resp["output"].each do |output_item|
35
- next unless output_item["type"] == "function_call"
36
-
37
- tool_calls << {
38
- "name" => output_item["name"],
39
- "arguments" => JSON.parse(output_item["arguments"])
40
- }
41
- end
42
-
43
- tool_calls.any? ? tool_calls : nil
44
- end
45
-
46
31
  def extract_raw_response(resp)
47
32
  text_outputs = []
48
33
 
@@ -110,6 +95,11 @@ private
110
95
  if supports_native_tool_use?
111
96
  tools = build_tools_parameter(model_completion)
112
97
  parameters[:tools] = tools unless tools.blank?
98
+
99
+ if model_completion.tool_choice.present?
100
+ tool_klass = model_completion.tool_choice.constantize
101
+ parameters[:tool_choice] = build_forced_tool_choice(tool_klass.tool_name)
102
+ end
113
103
  end
114
104
 
115
105
  # Add response format if needed. Default will be { "type": "text" }
@@ -3,6 +3,7 @@
3
3
  class Raif::Llms::OpenRouter < Raif::Llm
4
4
  include Raif::Concerns::Llms::OpenAiCompletions::MessageFormatting
5
5
  include Raif::Concerns::Llms::OpenAiCompletions::ToolFormatting
6
+ include Raif::Concerns::Llms::OpenAiCompletions::ResponseToolCalls
6
7
  include Raif::Concerns::Llms::OpenAi::JsonSchemaValidation
7
8
 
8
9
  def perform_model_completion!(model_completion, &block)
@@ -23,7 +24,7 @@ class Raif::Llms::OpenRouter < Raif::Llm
23
24
  private
24
25
 
25
26
  def connection
26
- @connection ||= Faraday.new(url: "https://openrouter.ai/api/v1") do |f|
27
+ @connection ||= Faraday.new(url: "https://openrouter.ai/api/v1", request: Raif.default_request_options) do |f|
27
28
  f.headers["Authorization"] = "Bearer #{Raif.config.open_router_api_key}"
28
29
  f.headers["HTTP-Referer"] = Raif.config.open_router_site_url if Raif.config.open_router_site_url.present?
29
30
  f.headers["X-Title"] = Raif.config.open_router_app_name if Raif.config.open_router_app_name.present?
@@ -85,6 +86,11 @@ private
85
86
  end
86
87
 
87
88
  params[:tools] = tools unless tools.blank?
89
+
90
+ if model_completion.tool_choice.present?
91
+ tool_klass = model_completion.tool_choice.constantize
92
+ params[:tool_choice] = build_forced_tool_choice(tool_klass.tool_name)
93
+ end
88
94
  end
89
95
 
90
96
  if model_completion.stream_response?
@@ -93,7 +99,9 @@ private
93
99
  params[:stream_options] = { include_usage: true }
94
100
  end
95
101
 
96
- if model_completion.response_format_json?
102
+ # OpenRouter will sometimes complain about combining response_format json and tool calling.
103
+ # If we're telling it to use the json_response tool, then the json_object response_format should be irrelevant.
104
+ if model_completion.response_format_json? && params[:tools].blank?
97
105
  params[:response_format] = { type: "json_object" }
98
106
  model_completion.response_format_parameter = "json_object"
99
107
  end
@@ -119,16 +127,4 @@ private
119
127
  extract_text_response(resp)
120
128
  end
121
129
  end
122
-
123
- def extract_response_tool_calls(resp)
124
- tool_calls = resp.dig("choices", 0, "message", "tool_calls")
125
- return if tool_calls.blank?
126
-
127
- tool_calls.map do |tool_call|
128
- {
129
- "name" => tool_call["function"]["name"],
130
- "arguments" => JSON.parse(tool_call["function"]["arguments"])
131
- }
132
- end
133
- end
134
130
  end
@@ -1,14 +1,68 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # == Schema Information
4
+ #
5
+ # Table name: raif_model_completions
6
+ #
7
+ # id :bigint not null, primary key
8
+ # available_model_tools :jsonb not null
9
+ # citations :jsonb
10
+ # completed_at :datetime
11
+ # completion_tokens :integer
12
+ # failed_at :datetime
13
+ # failure_error :string
14
+ # failure_reason :text
15
+ # llm_model_key :string not null
16
+ # max_completion_tokens :integer
17
+ # messages :jsonb not null
18
+ # model_api_name :string not null
19
+ # output_token_cost :decimal(10, 6)
20
+ # prompt_token_cost :decimal(10, 6)
21
+ # prompt_tokens :integer
22
+ # raw_response :text
23
+ # response_array :jsonb
24
+ # response_format :integer default("text"), not null
25
+ # response_format_parameter :string
26
+ # response_tool_calls :jsonb
27
+ # retry_count :integer default(0), not null
28
+ # source_type :string
29
+ # started_at :datetime
30
+ # stream_response :boolean default(FALSE), not null
31
+ # system_prompt :text
32
+ # temperature :decimal(5, 3)
33
+ # tool_choice :string
34
+ # total_cost :decimal(10, 6)
35
+ # total_tokens :integer
36
+ # created_at :datetime not null
37
+ # updated_at :datetime not null
38
+ # response_id :string
39
+ # source_id :bigint
40
+ #
41
+ # Indexes
42
+ #
43
+ # index_raif_model_completions_on_completed_at (completed_at)
44
+ # index_raif_model_completions_on_created_at (created_at)
45
+ # index_raif_model_completions_on_failed_at (failed_at)
46
+ # index_raif_model_completions_on_source (source_type,source_id)
47
+ # index_raif_model_completions_on_started_at (started_at)
48
+ #
3
49
  class Raif::ModelCompletion < Raif::ApplicationRecord
4
50
  include Raif::Concerns::LlmResponseParsing
5
51
  include Raif::Concerns::HasAvailableModelTools
52
+ include Raif::Concerns::BooleanTimestamp
53
+
54
+ boolean_timestamp :started_at
55
+ boolean_timestamp :completed_at
56
+ boolean_timestamp :failed_at
6
57
 
7
58
  belongs_to :source, polymorphic: true, optional: true
8
59
 
9
60
  validates :llm_model_key, presence: true, inclusion: { in: ->{ Raif.available_llm_keys.map(&:to_s) } }
10
61
  validates :model_api_name, presence: true
11
62
 
63
+ # Scope to find completions that have response tool calls
64
+ scope :with_response_tool_calls, -> { where_json_not_blank(:response_tool_calls) }
65
+
12
66
  delegate :json_response_schema, to: :source, allow_nil: true
13
67
 
14
68
  before_save :set_total_tokens
@@ -41,6 +95,13 @@ class Raif::ModelCompletion < Raif::ApplicationRecord
41
95
  end
42
96
  end
43
97
 
98
+ def record_failure!(exception)
99
+ self.failed_at = Time.current
100
+ self.failure_error = exception.class.name
101
+ self.failure_reason = exception.message.truncate(255)
102
+ save!
103
+ end
104
+
44
105
  private
45
106
 
46
107
  def llm_config
@@ -3,7 +3,7 @@
3
3
  class Raif::ModelTool
4
4
  include Raif::Concerns::JsonSchemaDefinition
5
5
 
6
- delegate :tool_name, :tool_description, :tool_arguments_schema, :example_model_invocation, to: :class
6
+ delegate :tool_name, :tool_description, :example_model_invocation, to: :class
7
7
 
8
8
  class << self
9
9
  # The description of the tool that will be provided to the model
@@ -76,8 +76,9 @@ class Raif::ModelTool
76
76
  false
77
77
  end
78
78
 
79
- def invoke_tool(tool_arguments:, source:)
79
+ def invoke_tool(provider_tool_call_id:, tool_arguments:, source:)
80
80
  tool_invocation = Raif::ModelToolInvocation.new(
81
+ provider_tool_call_id: provider_tool_call_id,
81
82
  source: source,
82
83
  tool_type: name,
83
84
  tool_arguments: tool_arguments
@@ -96,4 +97,11 @@ class Raif::ModelTool
96
97
  end
97
98
  end
98
99
 
100
+ # Instance method to get the tool arguments schema
101
+ # For instance-dependent schemas, builds the schema with this instance as context
102
+ # For class-level schemas, returns the class-level schema
103
+ def tool_arguments_schema
104
+ schema_for_instance(:tool_arguments)
105
+ end
106
+
99
107
  end
@@ -1,5 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # == Schema Information
4
+ #
5
+ # Table name: raif_model_tool_invocations
6
+ #
7
+ # id :bigint not null, primary key
8
+ # completed_at :datetime
9
+ # failed_at :datetime
10
+ # result :jsonb not null
11
+ # source_type :string not null
12
+ # tool_arguments :jsonb not null
13
+ # tool_type :string not null
14
+ # created_at :datetime not null
15
+ # updated_at :datetime not null
16
+ # provider_tool_call_id :string
17
+ # source_id :bigint not null
18
+ #
19
+ # Indexes
20
+ #
21
+ # index_raif_model_tool_invocations_on_source (source_type,source_id)
22
+ #
3
23
  class Raif::ModelToolInvocation < Raif::ApplicationRecord
4
24
  belongs_to :source, polymorphic: true
5
25
 
@@ -22,14 +42,26 @@ class Raif::ModelToolInvocation < Raif::ApplicationRecord
22
42
  @tool ||= tool_type.constantize
23
43
  end
24
44
 
25
- def as_llm_message
26
- "Invoking tool: #{tool_name} with arguments: #{tool_arguments.to_json}"
45
+ # Returns tool call in the format expected by LLM message formatting
46
+ # @param assistant_message [String, nil] Optional assistant message accompanying the tool call
47
+ # @return [Hash] Hash representation for JSONB storage and LLM APIs
48
+ def as_tool_call_message(assistant_message: nil)
49
+ Raif::Messages::ToolCall.new(
50
+ provider_tool_call_id: provider_tool_call_id,
51
+ name: tool_name,
52
+ arguments: tool_arguments,
53
+ assistant_message: assistant_message
54
+ ).to_h
27
55
  end
28
56
 
29
- def result_llm_message
30
- return unless tool.respond_to?(:observation_for_invocation)
31
-
32
- tool.observation_for_invocation(self)
57
+ # Returns tool result in the format expected by LLM message formatting
58
+ # @return [Hash] Hash representation for JSONB storage and LLM APIs
59
+ def as_tool_call_result_message
60
+ Raif::Messages::ToolCallResult.new(
61
+ provider_tool_call_id: provider_tool_call_id,
62
+ name: tool_name,
63
+ result: result
64
+ ).to_h
33
65
  end
34
66
 
35
67
  def to_partial_path