raif 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -5
  3. data/app/assets/builds/raif.css +4 -1
  4. data/app/assets/builds/raif_admin.css +13 -1
  5. data/app/assets/javascript/raif/controllers/conversations_controller.js +1 -1
  6. data/app/assets/stylesheets/raif/admin/conversation.scss +16 -0
  7. data/app/assets/stylesheets/raif/conversations.scss +3 -0
  8. data/app/assets/stylesheets/raif.scss +2 -1
  9. data/app/controllers/raif/admin/application_controller.rb +16 -0
  10. data/app/controllers/raif/admin/configs_controller.rb +94 -0
  11. data/app/controllers/raif/admin/model_completions_controller.rb +18 -1
  12. data/app/controllers/raif/admin/model_tool_invocations_controller.rb +7 -1
  13. data/app/controllers/raif/admin/stats/model_tool_invocations_controller.rb +21 -0
  14. data/app/controllers/raif/admin/stats/tasks_controller.rb +15 -6
  15. data/app/controllers/raif/admin/stats_controller.rb +32 -3
  16. data/app/controllers/raif/conversation_entries_controller.rb +1 -0
  17. data/app/controllers/raif/conversations_controller.rb +10 -2
  18. data/app/jobs/raif/conversation_entry_job.rb +8 -6
  19. data/app/models/raif/admin/task_stat.rb +7 -0
  20. data/app/models/raif/agent.rb +63 -2
  21. data/app/models/raif/agents/native_tool_calling_agent.rb +101 -56
  22. data/app/models/raif/application_record.rb +18 -0
  23. data/app/models/raif/concerns/agent_inference_stats.rb +35 -0
  24. data/app/models/raif/concerns/json_schema_definition.rb +40 -5
  25. data/app/models/raif/concerns/llms/anthropic/message_formatting.rb +28 -0
  26. data/app/models/raif/concerns/llms/anthropic/response_tool_calls.rb +24 -0
  27. data/app/models/raif/concerns/llms/anthropic/tool_formatting.rb +4 -0
  28. data/app/models/raif/concerns/llms/bedrock/message_formatting.rb +36 -0
  29. data/app/models/raif/concerns/llms/bedrock/response_tool_calls.rb +26 -0
  30. data/app/models/raif/concerns/llms/bedrock/tool_formatting.rb +4 -0
  31. data/app/models/raif/concerns/llms/google/message_formatting.rb +109 -0
  32. data/app/models/raif/concerns/llms/google/response_tool_calls.rb +32 -0
  33. data/app/models/raif/concerns/llms/google/tool_formatting.rb +72 -0
  34. data/app/models/raif/concerns/llms/message_formatting.rb +11 -5
  35. data/app/models/raif/concerns/llms/open_ai/json_schema_validation.rb +3 -3
  36. data/app/models/raif/concerns/llms/open_ai_completions/message_formatting.rb +22 -0
  37. data/app/models/raif/concerns/llms/open_ai_completions/response_tool_calls.rb +22 -0
  38. data/app/models/raif/concerns/llms/open_ai_completions/tool_formatting.rb +4 -0
  39. data/app/models/raif/concerns/llms/open_ai_responses/message_formatting.rb +17 -0
  40. data/app/models/raif/concerns/llms/open_ai_responses/response_tool_calls.rb +26 -0
  41. data/app/models/raif/concerns/llms/open_ai_responses/tool_formatting.rb +4 -0
  42. data/app/models/raif/concerns/run_with.rb +127 -0
  43. data/app/models/raif/conversation.rb +91 -8
  44. data/app/models/raif/conversation_entry.rb +32 -1
  45. data/app/models/raif/embedding_model.rb +2 -1
  46. data/app/models/raif/embedding_models/open_ai.rb +1 -1
  47. data/app/models/raif/llm.rb +27 -2
  48. data/app/models/raif/llms/anthropic.rb +7 -19
  49. data/app/models/raif/llms/bedrock.rb +6 -20
  50. data/app/models/raif/llms/google.rb +140 -0
  51. data/app/models/raif/llms/open_ai_base.rb +19 -5
  52. data/app/models/raif/llms/open_ai_completions.rb +6 -11
  53. data/app/models/raif/llms/open_ai_responses.rb +6 -16
  54. data/app/models/raif/llms/open_router.rb +7 -13
  55. data/app/models/raif/model_completion.rb +61 -0
  56. data/app/models/raif/model_tool.rb +10 -2
  57. data/app/models/raif/model_tool_invocation.rb +38 -6
  58. data/app/models/raif/model_tools/agent_final_answer.rb +2 -7
  59. data/app/models/raif/model_tools/provider_managed/code_execution.rb +4 -0
  60. data/app/models/raif/model_tools/provider_managed/image_generation.rb +4 -0
  61. data/app/models/raif/model_tools/provider_managed/web_search.rb +4 -0
  62. data/app/models/raif/streaming_responses/google.rb +71 -0
  63. data/app/models/raif/task.rb +55 -12
  64. data/app/models/raif/user_tool_invocation.rb +19 -0
  65. data/app/views/layouts/raif/admin.html.erb +12 -1
  66. data/app/views/raif/admin/agents/_agent.html.erb +8 -0
  67. data/app/views/raif/admin/agents/_conversation_message.html.erb +28 -6
  68. data/app/views/raif/admin/agents/index.html.erb +2 -0
  69. data/app/views/raif/admin/agents/show.html.erb +46 -1
  70. data/app/views/raif/admin/configs/show.html.erb +117 -0
  71. data/app/views/raif/admin/conversations/_conversation_entry.html.erb +29 -34
  72. data/app/views/raif/admin/conversations/show.html.erb +2 -0
  73. data/app/views/raif/admin/model_completions/_model_completion.html.erb +9 -0
  74. data/app/views/raif/admin/model_completions/index.html.erb +26 -0
  75. data/app/views/raif/admin/model_completions/show.html.erb +124 -61
  76. data/app/views/raif/admin/model_tool_invocations/index.html.erb +22 -1
  77. data/app/views/raif/admin/model_tools/_list.html.erb +16 -0
  78. data/app/views/raif/admin/model_tools/_model_tool.html.erb +36 -0
  79. data/app/views/raif/admin/stats/_stats_tile.html.erb +34 -0
  80. data/app/views/raif/admin/stats/index.html.erb +71 -88
  81. data/app/views/raif/admin/stats/model_tool_invocations/index.html.erb +43 -0
  82. data/app/views/raif/admin/stats/tasks/index.html.erb +20 -6
  83. data/app/views/raif/admin/tasks/index.html.erb +6 -1
  84. data/app/views/raif/admin/tasks/show.html.erb +36 -3
  85. data/app/views/raif/conversation_entries/_form.html.erb +3 -0
  86. data/app/views/raif/conversations/_conversation.html.erb +10 -0
  87. data/app/views/raif/conversations/_entry_processed.turbo_stream.erb +12 -0
  88. data/app/views/raif/conversations/index.html.erb +23 -0
  89. data/config/locales/admin.en.yml +33 -1
  90. data/config/locales/en.yml +33 -4
  91. data/config/routes.rb +2 -0
  92. data/db/migrate/20250904194456_add_generating_entry_response_to_raif_conversations.rb +7 -0
  93. data/db/migrate/20250911125234_add_source_to_raif_tasks.rb +7 -0
  94. data/db/migrate/20251020005853_add_source_to_raif_agents.rb +7 -0
  95. data/db/migrate/20251020011346_rename_task_run_args_to_run_with.rb +7 -0
  96. data/db/migrate/20251020011405_add_run_with_to_raif_agents.rb +13 -0
  97. data/db/migrate/20251024160119_add_llm_messages_max_length_to_raif_conversations.rb +14 -0
  98. data/db/migrate/20251124185033_add_provider_tool_call_id_to_raif_model_tool_invocations.rb +7 -0
  99. data/db/migrate/20251128202941_add_tool_choice_to_raif_model_completions.rb +7 -0
  100. data/db/migrate/20260118144846_add_source_to_raif_conversations.rb +7 -0
  101. data/db/migrate/20260119000000_add_failure_tracking_to_raif_model_completions.rb +10 -0
  102. data/db/migrate/20260119000001_add_completed_at_to_raif_model_completions.rb +8 -0
  103. data/db/migrate/20260119000002_add_started_at_to_raif_model_completions.rb +8 -0
  104. data/lib/generators/raif/agent/templates/agent.rb.tt +1 -1
  105. data/lib/generators/raif/agent/templates/application_agent.rb.tt +1 -1
  106. data/lib/generators/raif/conversation/templates/conversation.rb.tt +6 -0
  107. data/lib/generators/raif/install/templates/initializer.rb +78 -10
  108. data/lib/generators/raif/task/templates/task.rb.tt +1 -1
  109. data/lib/raif/configuration.rb +37 -2
  110. data/lib/raif/engine.rb +8 -0
  111. data/lib/raif/errors/instance_dependent_schema_error.rb +8 -0
  112. data/lib/raif/errors/streaming_error.rb +6 -3
  113. data/lib/raif/errors.rb +1 -0
  114. data/lib/raif/evals/llm_judge.rb +2 -2
  115. data/lib/raif/evals/llm_judges/binary.rb +3 -3
  116. data/lib/raif/evals/llm_judges/comparative.rb +3 -3
  117. data/lib/raif/evals/llm_judges/scored.rb +1 -1
  118. data/lib/raif/evals/llm_judges/summarization.rb +2 -2
  119. data/lib/raif/evals/run.rb +1 -0
  120. data/lib/raif/json_schema_builder.rb +14 -0
  121. data/lib/raif/llm_registry.rb +207 -37
  122. data/lib/raif/messages.rb +180 -0
  123. data/lib/raif/version.rb +1 -1
  124. data/lib/raif.rb +9 -0
  125. data/lib/tasks/annotate_rb.rake +10 -0
  126. data/spec/support/rspec_helpers.rb +8 -8
  127. metadata +44 -9
  128. data/app/models/raif/agents/re_act_agent.rb +0 -127
  129. data/app/models/raif/agents/re_act_step.rb +0 -32
  130. data/app/models/raif/concerns/task_run_args.rb +0 -62
@@ -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?
@@ -121,16 +127,4 @@ private
121
127
  extract_text_response(resp)
122
128
  end
123
129
  end
124
-
125
- def extract_response_tool_calls(resp)
126
- tool_calls = resp.dig("choices", 0, "message", "tool_calls")
127
- return if tool_calls.blank?
128
-
129
- tool_calls.map do |tool_call|
130
- {
131
- "name" => tool_call["function"]["name"],
132
- "arguments" => JSON.parse(tool_call["function"]["arguments"])
133
- }
134
- end
135
- end
136
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
@@ -20,16 +20,11 @@ class Raif::ModelTools::AgentFinalAnswer < Raif::ModelTool
20
20
  def observation_for_invocation(tool_invocation)
21
21
  return "No answer provided" unless tool_invocation.result.present?
22
22
 
23
- tool_invocation.result["final_answer"]
23
+ tool_invocation.result
24
24
  end
25
25
 
26
26
  def process_invocation(tool_invocation)
27
- tool_invocation.update!(
28
- result: {
29
- final_answer: tool_invocation.tool_arguments["final_answer"]
30
- }
31
- )
32
-
27
+ tool_invocation.update!(result: tool_invocation.tool_arguments["final_answer"])
33
28
  tool_invocation.result
34
29
  end
35
30
  end
@@ -2,4 +2,8 @@
2
2
 
3
3
  class Raif::ModelTools::ProviderManaged::CodeExecution < Raif::ModelTools::ProviderManaged::Base
4
4
 
5
+ tool_description do
6
+ "Utilizes the model provider's built-in code execution capabilities."
7
+ end
8
+
5
9
  end
@@ -2,4 +2,8 @@
2
2
 
3
3
  class Raif::ModelTools::ProviderManaged::ImageGeneration < Raif::ModelTools::ProviderManaged::Base
4
4
 
5
+ tool_description do
6
+ "Utilizes the model provider's built-in image generation capabilities."
7
+ end
8
+
5
9
  end
@@ -2,4 +2,8 @@
2
2
 
3
3
  class Raif::ModelTools::ProviderManaged::WebSearch < Raif::ModelTools::ProviderManaged::Base
4
4
 
5
+ tool_description do
6
+ "Utilizes the model provider's built-in web search capabilities."
7
+ end
8
+
5
9
  end