raif 1.0.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 (121) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +678 -0
  4. data/Rakefile +20 -0
  5. data/app/assets/builds/raif.css +74 -0
  6. data/app/assets/builds/raif_admin.css +266 -0
  7. data/app/assets/config/raif_manifest.js +1 -0
  8. data/app/assets/javascript/raif/controllers/conversations_controller.js +11 -0
  9. data/app/assets/javascript/raif/stream_actions/raif_scroll_to_bottom.js +12 -0
  10. data/app/assets/javascript/raif.js +10 -0
  11. data/app/assets/stylesheets/raif/admin/conversation.scss +64 -0
  12. data/app/assets/stylesheets/raif/loader.scss +85 -0
  13. data/app/assets/stylesheets/raif.scss +1 -0
  14. data/app/assets/stylesheets/raif_admin.scss +299 -0
  15. data/app/controllers/raif/admin/agents_controller.rb +17 -0
  16. data/app/controllers/raif/admin/application_controller.rb +20 -0
  17. data/app/controllers/raif/admin/conversations_controller.rb +17 -0
  18. data/app/controllers/raif/admin/model_completions_controller.rb +17 -0
  19. data/app/controllers/raif/admin/model_tool_invocations_controller.rb +17 -0
  20. data/app/controllers/raif/admin/tasks_controller.rb +23 -0
  21. data/app/controllers/raif/application_controller.rb +20 -0
  22. data/app/controllers/raif/conversation_entries_controller.rb +60 -0
  23. data/app/controllers/raif/conversations_controller.rb +58 -0
  24. data/app/helpers/raif/application_helper.rb +7 -0
  25. data/app/helpers/raif/shared/conversations_helper.rb +13 -0
  26. data/app/jobs/raif/application_job.rb +8 -0
  27. data/app/jobs/raif/conversation_entry_job.rb +30 -0
  28. data/app/models/raif/agent.rb +133 -0
  29. data/app/models/raif/agents/native_tool_calling_agent.rb +127 -0
  30. data/app/models/raif/agents/re_act_agent.rb +121 -0
  31. data/app/models/raif/agents/re_act_step.rb +33 -0
  32. data/app/models/raif/application_record.rb +14 -0
  33. data/app/models/raif/concerns/boolean_timestamp.rb +69 -0
  34. data/app/models/raif/concerns/has_available_model_tools.rb +13 -0
  35. data/app/models/raif/concerns/has_llm.rb +19 -0
  36. data/app/models/raif/concerns/has_requested_language.rb +20 -0
  37. data/app/models/raif/concerns/invokes_model_tools.rb +13 -0
  38. data/app/models/raif/concerns/llm_response_parsing.rb +44 -0
  39. data/app/models/raif/conversation.rb +67 -0
  40. data/app/models/raif/conversation_entry.rb +85 -0
  41. data/app/models/raif/llm.rb +88 -0
  42. data/app/models/raif/llms/anthropic.rb +120 -0
  43. data/app/models/raif/llms/bedrock_claude.rb +134 -0
  44. data/app/models/raif/llms/open_ai.rb +259 -0
  45. data/app/models/raif/model_completion.rb +28 -0
  46. data/app/models/raif/model_tool.rb +69 -0
  47. data/app/models/raif/model_tool_invocation.rb +43 -0
  48. data/app/models/raif/model_tools/agent_final_answer.rb +46 -0
  49. data/app/models/raif/model_tools/fetch_url.rb +57 -0
  50. data/app/models/raif/model_tools/wikipedia_search.rb +78 -0
  51. data/app/models/raif/task.rb +137 -0
  52. data/app/models/raif/user_tool_invocation.rb +29 -0
  53. data/app/views/layouts/raif/admin.html.erb +98 -0
  54. data/app/views/raif/admin/agents/_agent.html.erb +18 -0
  55. data/app/views/raif/admin/agents/_conversation_message.html.erb +15 -0
  56. data/app/views/raif/admin/agents/index.html.erb +33 -0
  57. data/app/views/raif/admin/agents/show.html.erb +131 -0
  58. data/app/views/raif/admin/conversations/_conversation.html.erb +7 -0
  59. data/app/views/raif/admin/conversations/_conversation_entry.html.erb +34 -0
  60. data/app/views/raif/admin/conversations/index.html.erb +32 -0
  61. data/app/views/raif/admin/conversations/show.html.erb +56 -0
  62. data/app/views/raif/admin/model_completions/_model_completion.html.erb +9 -0
  63. data/app/views/raif/admin/model_completions/index.html.erb +34 -0
  64. data/app/views/raif/admin/model_completions/show.html.erb +117 -0
  65. data/app/views/raif/admin/model_tool_invocations/_model_tool_invocation.html.erb +16 -0
  66. data/app/views/raif/admin/model_tool_invocations/index.html.erb +33 -0
  67. data/app/views/raif/admin/model_tool_invocations/show.html.erb +66 -0
  68. data/app/views/raif/admin/tasks/_task.html.erb +19 -0
  69. data/app/views/raif/admin/tasks/index.html.erb +49 -0
  70. data/app/views/raif/admin/tasks/show.html.erb +176 -0
  71. data/app/views/raif/conversation_entries/_conversation_entry.html.erb +26 -0
  72. data/app/views/raif/conversation_entries/_form.html.erb +25 -0
  73. data/app/views/raif/conversation_entries/_form_with_available_tools.html.erb +4 -0
  74. data/app/views/raif/conversation_entries/_form_with_user_tool_invocation.html.erb +18 -0
  75. data/app/views/raif/conversation_entries/_message.html.erb +17 -0
  76. data/app/views/raif/conversation_entries/_model_response_avatar.html.erb +1 -0
  77. data/app/views/raif/conversation_entries/_user_avatar.html.erb +1 -0
  78. data/app/views/raif/conversation_entries/create.turbo_stream.erb +11 -0
  79. data/app/views/raif/conversation_entries/new.turbo_stream.erb +6 -0
  80. data/app/views/raif/conversations/_available_user_tools.html.erb +11 -0
  81. data/app/views/raif/conversations/_full_conversation.html.erb +15 -0
  82. data/app/views/raif/conversations/show.html.erb +1 -0
  83. data/config/i18n-tasks.yml +181 -0
  84. data/config/importmap.rb +6 -0
  85. data/config/initializers/pagy.rb +14 -0
  86. data/config/locales/admin.en.yml +91 -0
  87. data/config/locales/en.yml +50 -0
  88. data/config/routes.rb +22 -0
  89. data/db/migrate/20250224234252_create_raif_tables.rb +114 -0
  90. data/lib/generators/raif/agent/agent_generator.rb +22 -0
  91. data/lib/generators/raif/agent/templates/agent.rb.tt +28 -0
  92. data/lib/generators/raif/conversation/conversation_generator.rb +27 -0
  93. data/lib/generators/raif/conversation/templates/conversation.rb.tt +37 -0
  94. data/lib/generators/raif/install/install_generator.rb +31 -0
  95. data/lib/generators/raif/install/templates/initializer.rb +81 -0
  96. data/lib/generators/raif/model_tool/model_tool_generator.rb +27 -0
  97. data/lib/generators/raif/model_tool/templates/model_tool.rb.tt +74 -0
  98. data/lib/generators/raif/task/task_generator.rb +28 -0
  99. data/lib/generators/raif/task/templates/application_task.rb.tt +7 -0
  100. data/lib/generators/raif/task/templates/task.rb.tt +52 -0
  101. data/lib/generators/raif/views_generator.rb +22 -0
  102. data/lib/raif/configuration.rb +82 -0
  103. data/lib/raif/default_llms.rb +37 -0
  104. data/lib/raif/engine.rb +86 -0
  105. data/lib/raif/errors/action_not_authorized_error.rb +8 -0
  106. data/lib/raif/errors/anthropic/api_error.rb +10 -0
  107. data/lib/raif/errors/invalid_config_error.rb +8 -0
  108. data/lib/raif/errors/invalid_conversation_type_error.rb +8 -0
  109. data/lib/raif/errors/invalid_user_tool_type_error.rb +8 -0
  110. data/lib/raif/errors/open_ai/api_error.rb +10 -0
  111. data/lib/raif/errors/open_ai/json_schema_error.rb +10 -0
  112. data/lib/raif/errors.rb +9 -0
  113. data/lib/raif/languages.rb +33 -0
  114. data/lib/raif/rspec.rb +7 -0
  115. data/lib/raif/utils/html_to_markdown_converter.rb +7 -0
  116. data/lib/raif/utils/readable_content_extractor.rb +61 -0
  117. data/lib/raif/utils.rb +6 -0
  118. data/lib/raif/version.rb +5 -0
  119. data/lib/raif.rb +65 -0
  120. data/lib/tasks/raif_tasks.rake +6 -0
  121. metadata +294 -0
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Raif::ConversationEntry < Raif::ApplicationRecord
4
+ include Raif::Concerns::InvokesModelTools
5
+ include Raif::Concerns::HasAvailableModelTools
6
+
7
+ belongs_to :raif_conversation, counter_cache: true, class_name: "Raif::Conversation"
8
+ belongs_to :creator, polymorphic: true
9
+
10
+ has_one :raif_user_tool_invocation,
11
+ class_name: "Raif::UserToolInvocation",
12
+ dependent: :destroy,
13
+ foreign_key: :raif_conversation_entry_id,
14
+ inverse_of: :raif_conversation_entry
15
+
16
+ has_one :raif_model_completion, as: :source, dependent: :destroy, class_name: "Raif::ModelCompletion"
17
+
18
+ delegate :available_model_tools, to: :raif_conversation
19
+ delegate :system_prompt, :llm_model_key, to: :raif_model_completion, allow_nil: true
20
+ delegate :json_response_schema, to: :class
21
+
22
+ accepts_nested_attributes_for :raif_user_tool_invocation
23
+
24
+ boolean_timestamp :started_at
25
+ boolean_timestamp :completed_at
26
+ boolean_timestamp :failed_at
27
+
28
+ before_validation :add_user_tool_invocation_to_user_message, on: :create
29
+
30
+ normalizes :model_response_message, with: ->(value) { value&.strip }
31
+ normalizes :user_message, with: ->(value) { value&.strip }
32
+
33
+ def add_user_tool_invocation_to_user_message
34
+ return unless raif_user_tool_invocation.present?
35
+
36
+ self.user_message = <<~MESSAGE.strip
37
+ #{raif_user_tool_invocation.as_user_message}
38
+
39
+ #{user_message}
40
+ MESSAGE
41
+ end
42
+
43
+ def generating_response?
44
+ started? && !completed? && !failed?
45
+ end
46
+
47
+ def process_entry!
48
+ self.raif_model_completion = raif_conversation.prompt_model_for_entry_response(entry: self)
49
+
50
+ if raif_model_completion.parsed_response.present? || raif_model_completion.response_tool_calls.present?
51
+ extract_message_and_invoke_tools!
52
+ else
53
+ logger.error "Error processing conversation entry ##{id}. No model response found."
54
+ failed!
55
+ end
56
+
57
+ self
58
+ end
59
+
60
+ private
61
+
62
+ def extract_message_and_invoke_tools!
63
+ transaction do
64
+ self.raw_response = raif_model_completion.raw_response
65
+ self.model_response_message = raif_model_completion.parsed_response
66
+ save!
67
+
68
+ if raif_model_completion.response_tool_calls.present?
69
+ raif_model_completion.response_tool_calls.each do |tool_call|
70
+ tool_klass = available_model_tools_map[tool_call["name"]]
71
+ tool_klass&.invoke_tool(tool_arguments: tool_call["arguments"], source: self)
72
+ end
73
+ end
74
+
75
+ completed!
76
+ end
77
+ rescue StandardError => e
78
+ logger.error "Error processing conversation entry ##{id}. Error: #{e.message}"
79
+ logger.error e.backtrace.join("\n")
80
+ failed!
81
+
82
+ raise e
83
+ end
84
+
85
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif
4
+ class Llm
5
+ include ActiveModel::Model
6
+
7
+ attr_accessor :key,
8
+ :api_name,
9
+ :default_temperature,
10
+ :default_max_completion_tokens,
11
+ :supports_native_tool_use,
12
+ :provider_settings
13
+
14
+ validates :key, presence: true
15
+ validates :api_name, presence: true
16
+
17
+ VALID_RESPONSE_FORMATS = [:text, :json, :html].freeze
18
+
19
+ alias_method :supports_native_tool_use?, :supports_native_tool_use
20
+
21
+ def initialize(key:, api_name:, model_provider_settings: {}, supports_native_tool_use: true, temperature: nil, max_completion_tokens: nil)
22
+ @key = key
23
+ @api_name = api_name
24
+ @provider_settings = model_provider_settings
25
+ @supports_native_tool_use = supports_native_tool_use
26
+ @default_temperature = temperature || 0.7
27
+ @default_max_completion_tokens = max_completion_tokens
28
+ end
29
+
30
+ def name
31
+ I18n.t("raif.model_names.#{key}")
32
+ end
33
+
34
+ def chat(message: nil, messages: nil, response_format: :text, available_model_tools: [], source: nil, system_prompt: nil, temperature: nil,
35
+ max_completion_tokens: nil)
36
+ unless response_format.is_a?(Symbol)
37
+ raise ArgumentError,
38
+ "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
39
+ end
40
+
41
+ unless VALID_RESPONSE_FORMATS.include?(response_format)
42
+ raise ArgumentError, "Raif::Llm#chat - Invalid response format: #{response_format}. Must be one of: #{VALID_RESPONSE_FORMATS.join(", ")}"
43
+ end
44
+
45
+ unless message.present? || messages.present?
46
+ raise ArgumentError, "Raif::Llm#chat - You must provide either a message: or messages: argument"
47
+ end
48
+
49
+ if message.present? && messages.present?
50
+ raise ArgumentError, "Raif::Llm#chat - You must provide either a message: or messages: argument, not both"
51
+ end
52
+
53
+ unless Raif.config.llm_api_requests_enabled
54
+ Raif.logger.warn("LLM API requests are disabled. Skipping request to #{api_name}.")
55
+ return
56
+ end
57
+
58
+ messages = [{ role: "user", content: message }] if message.present?
59
+
60
+ temperature ||= default_temperature
61
+ max_completion_tokens ||= default_max_completion_tokens
62
+
63
+ model_completion = Raif::ModelCompletion.new(
64
+ messages: messages,
65
+ system_prompt: system_prompt,
66
+ response_format: response_format,
67
+ source: source,
68
+ llm_model_key: key.to_s,
69
+ model_api_name: api_name,
70
+ temperature: temperature,
71
+ max_completion_tokens: max_completion_tokens,
72
+ available_model_tools: available_model_tools
73
+ )
74
+
75
+ perform_model_completion!(model_completion)
76
+ model_completion
77
+ end
78
+
79
+ def perform_model_completion!(model_completion)
80
+ raise NotImplementedError, "Raif::Llm subclasses must implement #perform_model_completion!"
81
+ end
82
+
83
+ def self.valid_response_formats
84
+ VALID_RESPONSE_FORMATS
85
+ end
86
+
87
+ end
88
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Raif::Llms::Anthropic < Raif::Llm
4
+
5
+ def perform_model_completion!(model_completion)
6
+ params = build_api_parameters(model_completion)
7
+
8
+ response = connection.post("messages") do |req|
9
+ req.body = params.to_json
10
+ end
11
+
12
+ resp = JSON.parse(response.body, symbolize_names: true)
13
+
14
+ # Handle API errors
15
+ unless response.success?
16
+ error_message = resp[:error]&.dig(:message) || "Anthropic API error: #{response.status}"
17
+ raise Raif::Errors::Anthropic::ApiError, error_message
18
+ end
19
+
20
+ model_completion.raw_response = if model_completion.response_format_json?
21
+ extract_json_response(resp)
22
+ else
23
+ extract_text_response(resp)
24
+ end
25
+
26
+ model_completion.response_tool_calls = extract_response_tool_calls(resp)
27
+ model_completion.completion_tokens = resp&.dig(:usage, :output_tokens)
28
+ model_completion.prompt_tokens = resp&.dig(:usage, :input_tokens)
29
+ model_completion.save!
30
+
31
+ model_completion
32
+ end
33
+
34
+ def connection
35
+ @connection ||= Faraday.new(url: "https://api.anthropic.com/v1") do |f|
36
+ f.headers["Content-Type"] = "application/json"
37
+ f.headers["x-api-key"] = Raif.config.anthropic_api_key
38
+ f.headers["anthropic-version"] = "2023-06-01"
39
+ end
40
+ end
41
+
42
+ protected
43
+
44
+ def build_api_parameters(model_completion)
45
+ params = {
46
+ model: model_completion.model_api_name,
47
+ messages: model_completion.messages,
48
+ temperature: (model_completion.temperature || default_temperature).to_f,
49
+ max_tokens: model_completion.max_completion_tokens || default_max_completion_tokens
50
+ }
51
+
52
+ params[:system] = model_completion.system_prompt if model_completion.system_prompt.present?
53
+
54
+ # Add tools to the request if needed
55
+ tools = []
56
+
57
+ # If we're looking for a JSON response, add a tool to the request that the model can use to provide a JSON response
58
+ if model_completion.response_format_json? && model_completion.json_response_schema.present?
59
+ tools << {
60
+ name: "json_response",
61
+ description: "Generate a structured JSON response based on the provided schema.",
62
+ input_schema: model_completion.json_response_schema
63
+ }
64
+ end
65
+
66
+ # If we support native tool use and have tools available, add them to the request
67
+ if supports_native_tool_use? && model_completion.available_model_tools.any?
68
+ model_completion.available_model_tools_map.each do |_tool_name, tool|
69
+ tools << {
70
+ name: tool.tool_name,
71
+ description: tool.tool_description,
72
+ input_schema: tool.tool_arguments_schema
73
+ }
74
+ end
75
+ end
76
+
77
+ params[:tools] = tools if tools.any?
78
+
79
+ params
80
+ end
81
+
82
+ def extract_text_response(resp)
83
+ resp&.dig(:content)&.first&.dig(:text)
84
+ end
85
+
86
+ def extract_json_response(resp)
87
+ return extract_text_response(resp) if resp&.dig(:content).nil?
88
+
89
+ # Look for tool_use blocks in the content array
90
+ tool_name = "json_response"
91
+ tool_response = resp&.dig(:content)&.find do |content|
92
+ content[:type] == "tool_use" && content[:name] == tool_name
93
+ end
94
+
95
+ if tool_response
96
+ JSON.generate(tool_response[:input])
97
+ else
98
+ extract_text_response(resp)
99
+ end
100
+ end
101
+
102
+ def extract_response_tool_calls(resp)
103
+ return if resp&.dig(:content).nil?
104
+
105
+ # Find any tool_use content blocks
106
+ tool_uses = resp&.dig(:content)&.select do |content|
107
+ content[:type] == "tool_use"
108
+ end
109
+
110
+ return if tool_uses.blank?
111
+
112
+ tool_uses.map do |tool_use|
113
+ {
114
+ "name" => tool_use[:name],
115
+ "arguments" => tool_use[:input]
116
+ }
117
+ end
118
+ end
119
+
120
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Raif::Llms::BedrockClaude < Raif::Llm
4
+
5
+ def perform_model_completion!(model_completion)
6
+ if Raif.config.aws_bedrock_model_name_prefix.present?
7
+ model_completion.model_api_name = "#{Raif.config.aws_bedrock_model_name_prefix}.#{model_completion.model_api_name}"
8
+ end
9
+
10
+ params = build_api_parameters(model_completion)
11
+ resp = bedrock_client.converse(params)
12
+
13
+ model_completion.raw_response = if model_completion.response_format_json?
14
+ extract_json_response(resp)
15
+ else
16
+ extract_text_response(resp)
17
+ end
18
+
19
+ model_completion.completion_tokens = resp.usage.output_tokens
20
+ model_completion.prompt_tokens = resp.usage.input_tokens
21
+ model_completion.total_tokens = resp.usage.total_tokens
22
+ model_completion.save!
23
+
24
+ model_completion
25
+ end
26
+
27
+ protected
28
+
29
+ def bedrock_client
30
+ @bedrock_client ||= Aws::BedrockRuntime::Client.new(region: Raif.config.aws_bedrock_region)
31
+ end
32
+
33
+ def format_messages(messages)
34
+ messages.map(&:symbolize_keys).map do |message|
35
+ {
36
+ role: message[:role],
37
+ content: [{ text: message[:content] }]
38
+ }
39
+ end
40
+ end
41
+
42
+ def build_api_parameters(model_completion)
43
+ params = {
44
+ model_id: model_completion.model_api_name,
45
+ inference_config: { max_tokens: model_completion.max_completion_tokens || 8192 },
46
+ messages: format_messages(model_completion.messages)
47
+ }
48
+
49
+ params[:system] = [{ text: model_completion.system_prompt }] if model_completion.system_prompt.present?
50
+
51
+ # Prepare tools configuration if needed
52
+ tools = []
53
+
54
+ # If we're looking for a JSON response, add a tool to the request that the model can use to provide a JSON response
55
+ if model_completion.response_format_json? && model_completion.json_response_schema.present?
56
+ tools << {
57
+ name: "json_response",
58
+ description: "Generate a structured JSON response based on the provided schema.",
59
+ input_schema: { json: model_completion.json_response_schema }
60
+ }
61
+ end
62
+
63
+ # If we support native tool use and have tools available, add them to the request
64
+ if supports_native_tool_use? && model_completion.available_model_tools.any?
65
+ model_completion.available_model_tools_map.each do |_tool_name, tool|
66
+ tools << {
67
+ name: tool.tool_name,
68
+ description: tool.tool_description,
69
+ input_schema: { json: tool.tool_arguments_schema }
70
+ }
71
+ end
72
+ end
73
+
74
+ # Add tool configuration if any tools are available
75
+ if tools.any?
76
+ params[:tool_config] = {
77
+ tools: tools.map { |tool| { tool_spec: tool } }
78
+ }
79
+ end
80
+
81
+ params
82
+ end
83
+
84
+ def extract_text_response(resp)
85
+ # Get the message from the response object
86
+ message = resp.output.message
87
+
88
+ # Find the first text content block
89
+ text_block = message.content&.find do |content|
90
+ content.respond_to?(:text) && content.text.present?
91
+ end
92
+
93
+ text_block&.text
94
+ end
95
+
96
+ def extract_json_response(resp)
97
+ # Get the message from the response object
98
+ message = resp.output.message
99
+
100
+ return extract_text_response(resp) if message.content.nil?
101
+
102
+ # Look for tool_use blocks in the content array
103
+ tool_response = message.content.find do |content|
104
+ content.respond_to?(:tool_use) && content.tool_use.present? && content.tool_use.name == "json_response"
105
+ end
106
+
107
+ if tool_response&.tool_use
108
+ JSON.generate(tool_response.tool_use.input)
109
+ else
110
+ extract_text_response(resp)
111
+ end
112
+ end
113
+
114
+ def extract_response_tool_calls(resp)
115
+ # Get the message from the response object
116
+ message = resp.output.message
117
+ return if message.content.nil?
118
+
119
+ # Find any tool_use blocks in the content array
120
+ tool_uses = message.content.select do |content|
121
+ content.respond_to?(:tool_use) && content.tool_use.present?
122
+ end
123
+
124
+ return if tool_uses.blank?
125
+
126
+ tool_uses.map do |content|
127
+ {
128
+ "name" => content.tool_use.name,
129
+ "arguments" => content.tool_use.input
130
+ }
131
+ end
132
+ end
133
+
134
+ end
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Raif::Llms::OpenAi < Raif::Llm
4
+
5
+ def perform_model_completion!(model_completion)
6
+ model_completion.temperature ||= default_temperature
7
+ parameters = build_chat_parameters(model_completion)
8
+
9
+ response = connection.post("chat/completions") do |req|
10
+ req.body = parameters.to_json
11
+ end
12
+
13
+ resp = JSON.parse(response.body)
14
+
15
+ # Handle API errors
16
+ unless response.success?
17
+ error_message = resp["error"]&.dig("message") || "OpenAI API error: #{response.status}"
18
+ raise Raif::Errors::OpenAi::ApiError, error_message
19
+ end
20
+
21
+ model_completion.update!(
22
+ response_tool_calls: extract_response_tool_calls(resp),
23
+ raw_response: resp.dig("choices", 0, "message", "content"),
24
+ completion_tokens: resp["usage"]["completion_tokens"],
25
+ prompt_tokens: resp["usage"]["prompt_tokens"],
26
+ total_tokens: resp["usage"]["total_tokens"],
27
+ response_format_parameter: parameters.dig(:response_format, :type)
28
+ )
29
+
30
+ model_completion
31
+ end
32
+
33
+ def connection
34
+ @connection ||= Faraday.new(url: "https://api.openai.com/v1") do |f|
35
+ f.headers["Content-Type"] = "application/json"
36
+ f.headers["Authorization"] = "Bearer #{Raif.config.open_ai_api_key}"
37
+ end
38
+ end
39
+
40
+ def validate_json_schema!(schema)
41
+ return if schema.blank?
42
+
43
+ errors = []
44
+
45
+ # Check if schema is present
46
+ if schema.blank?
47
+ errors << "JSON schema must include a 'schema' property"
48
+ else
49
+ # Check root object type
50
+ if schema[:type] != "object" && !schema.key?(:properties)
51
+ errors << "Root schema must be of type 'object' with 'properties'"
52
+ end
53
+
54
+ # Check all objects in the schema recursively
55
+ validate_object_properties(schema, errors)
56
+
57
+ # Check properties count (max 100 total)
58
+ validate_properties_count(schema, errors)
59
+
60
+ # Check nesting depth (max 5 levels)
61
+ validate_nesting_depth(schema, errors)
62
+
63
+ # Check for unsupported anyOf at root level
64
+ if schema[:anyOf].present? && schema[:properties].blank?
65
+ errors << "Root objects cannot be of type 'anyOf'"
66
+ end
67
+ end
68
+
69
+ # Raise error if any validation issues found
70
+ if errors.any?
71
+ error_message = "Invalid JSON schema for OpenAI structured outputs: #{errors.join("; ")}\nSchema was: #{schema.inspect}"
72
+ raise Raif::Errors::OpenAi::JsonSchemaError, error_message
73
+ else
74
+ true
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def extract_response_tool_calls(resp)
81
+ return if resp.dig("choices", 0, "message", "tool_calls").blank?
82
+
83
+ resp.dig("choices", 0, "message", "tool_calls").map do |tool_call|
84
+ {
85
+ "name" => tool_call["function"]["name"],
86
+ "arguments" => JSON.parse(tool_call["function"]["arguments"])
87
+ }
88
+ end
89
+ end
90
+
91
+ def build_chat_parameters(model_completion)
92
+ formatted_system_prompt = model_completion.system_prompt.to_s.strip
93
+
94
+ # If the response format is JSON, we need to include "as json" in the system prompt.
95
+ # OpenAI requires this and will throw an error if it's not included.
96
+ if model_completion.response_format_json?
97
+ # Ensure system prompt ends with a period if not empty
98
+ if formatted_system_prompt.present? && !formatted_system_prompt.end_with?(".", "?", "!")
99
+ formatted_system_prompt += "."
100
+ end
101
+ formatted_system_prompt += " Return your response as JSON."
102
+ formatted_system_prompt.strip!
103
+ end
104
+
105
+ messages = model_completion.messages
106
+ messages_with_system = if !formatted_system_prompt.empty?
107
+ [{ "role" => "system", "content" => formatted_system_prompt }] + messages
108
+ else
109
+ messages
110
+ end
111
+
112
+ parameters = {
113
+ model: api_name,
114
+ messages: messages_with_system,
115
+ temperature: model_completion.temperature.to_f
116
+ }
117
+
118
+ # If the LLM supports native tool use and there are available tools, add them to the parameters
119
+ if supports_native_tool_use? && model_completion.available_model_tools.any?
120
+ parameters[:tools] = model_completion.available_model_tools_map.map do |_tool_name, tool|
121
+ validate_json_schema!(tool.tool_arguments_schema)
122
+
123
+ {
124
+ type: "function",
125
+ function: {
126
+ name: tool.tool_name,
127
+ description: tool.tool_description,
128
+ parameters: tool.tool_arguments_schema
129
+ }
130
+ }
131
+ end
132
+ end
133
+
134
+ # Add response format if needed
135
+ response_format = determine_response_format(model_completion)
136
+ parameters[:response_format] = response_format if response_format
137
+
138
+ parameters
139
+ end
140
+
141
+ def determine_response_format(model_completion)
142
+ # Only configure response format for JSON outputs
143
+ return unless model_completion.response_format_json?
144
+
145
+ if model_completion.json_response_schema.present? && supports_structured_outputs?
146
+ validate_json_schema!(model_completion.json_response_schema)
147
+
148
+ {
149
+ type: "json_schema",
150
+ json_schema: {
151
+ name: "json_response_schema",
152
+ strict: true,
153
+ schema: model_completion.json_response_schema
154
+ }
155
+ }
156
+ else
157
+ # Default JSON mode for OpenAI models that don't support structured outputs or no schema is provided
158
+ { type: "json_object" }
159
+ end
160
+ end
161
+
162
+ def supports_structured_outputs?
163
+ # Not all OpenAI models support structured outputs:
164
+ # https://platform.openai.com/docs/guides/structured-outputs?api-mode=chat#supported-models
165
+ provider_settings[:supports_structured_outputs]
166
+ end
167
+
168
+ def validate_object_properties(schema, errors)
169
+ return unless schema.is_a?(Hash)
170
+
171
+ # Check if the current schema is an object and validate additionalProperties and required fields
172
+ if schema[:type] == "object"
173
+ if schema[:additionalProperties] != false
174
+ errors << "All objects must have 'additionalProperties' set to false"
175
+ end
176
+
177
+ # Check that all properties are required
178
+ if schema[:properties].is_a?(Hash) && schema[:properties].any?
179
+ property_keys = schema[:properties].keys
180
+ required_fields = schema[:required] || []
181
+
182
+ if required_fields.sort != property_keys.map(&:to_s).sort
183
+ errors << "All object properties must be listed in the 'required' array"
184
+ end
185
+ end
186
+ end
187
+
188
+ # Check if the current schema is an object and validate additionalProperties
189
+ if schema[:type] == "object"
190
+ if schema[:additionalProperties] != false
191
+ errors << "All objects must have 'additionalProperties' set to false"
192
+ end
193
+
194
+ # Check properties of the object recursively
195
+ if schema[:properties].is_a?(Hash)
196
+ schema[:properties].each_value do |property|
197
+ validate_object_properties(property, errors)
198
+ end
199
+ end
200
+ end
201
+
202
+ # Check array items
203
+ if schema[:type] == "array" && schema[:items].is_a?(Hash)
204
+ validate_object_properties(schema[:items], errors)
205
+ end
206
+
207
+ # Check anyOf
208
+ if schema[:anyOf].is_a?(Array)
209
+ schema[:anyOf].each do |option|
210
+ validate_object_properties(option, errors)
211
+ end
212
+ end
213
+ end
214
+
215
+ def validate_properties_count(schema, errors, count = 0)
216
+ return count unless schema.is_a?(Hash)
217
+
218
+ if schema[:properties].is_a?(Hash)
219
+ count += schema[:properties].size
220
+
221
+ if count > 100
222
+ errors << "Schema exceeds maximum of 100 total object properties"
223
+ return count
224
+ end
225
+
226
+ # Check nested properties
227
+ schema[:properties].each_value do |property|
228
+ count = validate_properties_count(property, errors, count)
229
+ end
230
+ end
231
+
232
+ # Check array items
233
+ if schema[:type] == "array" && schema[:items].is_a?(Hash)
234
+ count = validate_properties_count(schema[:items], errors, count)
235
+ end
236
+
237
+ count
238
+ end
239
+
240
+ def validate_nesting_depth(schema, errors, depth = 1)
241
+ return unless schema.is_a?(Hash)
242
+
243
+ if depth > 5
244
+ errors << "Schema exceeds maximum nesting depth of 5 levels"
245
+ return
246
+ end
247
+
248
+ if schema[:properties].is_a?(Hash)
249
+ schema[:properties].each_value do |property|
250
+ validate_nesting_depth(property, errors, depth + 1)
251
+ end
252
+ end
253
+
254
+ # Check array items
255
+ if schema[:type] == "array" && schema[:items].is_a?(Hash)
256
+ validate_nesting_depth(schema[:items], errors, depth + 1)
257
+ end
258
+ end
259
+ end