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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +678 -0
- data/Rakefile +20 -0
- data/app/assets/builds/raif.css +74 -0
- data/app/assets/builds/raif_admin.css +266 -0
- data/app/assets/config/raif_manifest.js +1 -0
- data/app/assets/javascript/raif/controllers/conversations_controller.js +11 -0
- data/app/assets/javascript/raif/stream_actions/raif_scroll_to_bottom.js +12 -0
- data/app/assets/javascript/raif.js +10 -0
- data/app/assets/stylesheets/raif/admin/conversation.scss +64 -0
- data/app/assets/stylesheets/raif/loader.scss +85 -0
- data/app/assets/stylesheets/raif.scss +1 -0
- data/app/assets/stylesheets/raif_admin.scss +299 -0
- data/app/controllers/raif/admin/agents_controller.rb +17 -0
- data/app/controllers/raif/admin/application_controller.rb +20 -0
- data/app/controllers/raif/admin/conversations_controller.rb +17 -0
- data/app/controllers/raif/admin/model_completions_controller.rb +17 -0
- data/app/controllers/raif/admin/model_tool_invocations_controller.rb +17 -0
- data/app/controllers/raif/admin/tasks_controller.rb +23 -0
- data/app/controllers/raif/application_controller.rb +20 -0
- data/app/controllers/raif/conversation_entries_controller.rb +60 -0
- data/app/controllers/raif/conversations_controller.rb +58 -0
- data/app/helpers/raif/application_helper.rb +7 -0
- data/app/helpers/raif/shared/conversations_helper.rb +13 -0
- data/app/jobs/raif/application_job.rb +8 -0
- data/app/jobs/raif/conversation_entry_job.rb +30 -0
- data/app/models/raif/agent.rb +133 -0
- data/app/models/raif/agents/native_tool_calling_agent.rb +127 -0
- data/app/models/raif/agents/re_act_agent.rb +121 -0
- data/app/models/raif/agents/re_act_step.rb +33 -0
- data/app/models/raif/application_record.rb +14 -0
- data/app/models/raif/concerns/boolean_timestamp.rb +69 -0
- data/app/models/raif/concerns/has_available_model_tools.rb +13 -0
- data/app/models/raif/concerns/has_llm.rb +19 -0
- data/app/models/raif/concerns/has_requested_language.rb +20 -0
- data/app/models/raif/concerns/invokes_model_tools.rb +13 -0
- data/app/models/raif/concerns/llm_response_parsing.rb +44 -0
- data/app/models/raif/conversation.rb +67 -0
- data/app/models/raif/conversation_entry.rb +85 -0
- data/app/models/raif/llm.rb +88 -0
- data/app/models/raif/llms/anthropic.rb +120 -0
- data/app/models/raif/llms/bedrock_claude.rb +134 -0
- data/app/models/raif/llms/open_ai.rb +259 -0
- data/app/models/raif/model_completion.rb +28 -0
- data/app/models/raif/model_tool.rb +69 -0
- data/app/models/raif/model_tool_invocation.rb +43 -0
- data/app/models/raif/model_tools/agent_final_answer.rb +46 -0
- data/app/models/raif/model_tools/fetch_url.rb +57 -0
- data/app/models/raif/model_tools/wikipedia_search.rb +78 -0
- data/app/models/raif/task.rb +137 -0
- data/app/models/raif/user_tool_invocation.rb +29 -0
- data/app/views/layouts/raif/admin.html.erb +98 -0
- data/app/views/raif/admin/agents/_agent.html.erb +18 -0
- data/app/views/raif/admin/agents/_conversation_message.html.erb +15 -0
- data/app/views/raif/admin/agents/index.html.erb +33 -0
- data/app/views/raif/admin/agents/show.html.erb +131 -0
- data/app/views/raif/admin/conversations/_conversation.html.erb +7 -0
- data/app/views/raif/admin/conversations/_conversation_entry.html.erb +34 -0
- data/app/views/raif/admin/conversations/index.html.erb +32 -0
- data/app/views/raif/admin/conversations/show.html.erb +56 -0
- data/app/views/raif/admin/model_completions/_model_completion.html.erb +9 -0
- data/app/views/raif/admin/model_completions/index.html.erb +34 -0
- data/app/views/raif/admin/model_completions/show.html.erb +117 -0
- data/app/views/raif/admin/model_tool_invocations/_model_tool_invocation.html.erb +16 -0
- data/app/views/raif/admin/model_tool_invocations/index.html.erb +33 -0
- data/app/views/raif/admin/model_tool_invocations/show.html.erb +66 -0
- data/app/views/raif/admin/tasks/_task.html.erb +19 -0
- data/app/views/raif/admin/tasks/index.html.erb +49 -0
- data/app/views/raif/admin/tasks/show.html.erb +176 -0
- data/app/views/raif/conversation_entries/_conversation_entry.html.erb +26 -0
- data/app/views/raif/conversation_entries/_form.html.erb +25 -0
- data/app/views/raif/conversation_entries/_form_with_available_tools.html.erb +4 -0
- data/app/views/raif/conversation_entries/_form_with_user_tool_invocation.html.erb +18 -0
- data/app/views/raif/conversation_entries/_message.html.erb +17 -0
- data/app/views/raif/conversation_entries/_model_response_avatar.html.erb +1 -0
- data/app/views/raif/conversation_entries/_user_avatar.html.erb +1 -0
- data/app/views/raif/conversation_entries/create.turbo_stream.erb +11 -0
- data/app/views/raif/conversation_entries/new.turbo_stream.erb +6 -0
- data/app/views/raif/conversations/_available_user_tools.html.erb +11 -0
- data/app/views/raif/conversations/_full_conversation.html.erb +15 -0
- data/app/views/raif/conversations/show.html.erb +1 -0
- data/config/i18n-tasks.yml +181 -0
- data/config/importmap.rb +6 -0
- data/config/initializers/pagy.rb +14 -0
- data/config/locales/admin.en.yml +91 -0
- data/config/locales/en.yml +50 -0
- data/config/routes.rb +22 -0
- data/db/migrate/20250224234252_create_raif_tables.rb +114 -0
- data/lib/generators/raif/agent/agent_generator.rb +22 -0
- data/lib/generators/raif/agent/templates/agent.rb.tt +28 -0
- data/lib/generators/raif/conversation/conversation_generator.rb +27 -0
- data/lib/generators/raif/conversation/templates/conversation.rb.tt +37 -0
- data/lib/generators/raif/install/install_generator.rb +31 -0
- data/lib/generators/raif/install/templates/initializer.rb +81 -0
- data/lib/generators/raif/model_tool/model_tool_generator.rb +27 -0
- data/lib/generators/raif/model_tool/templates/model_tool.rb.tt +74 -0
- data/lib/generators/raif/task/task_generator.rb +28 -0
- data/lib/generators/raif/task/templates/application_task.rb.tt +7 -0
- data/lib/generators/raif/task/templates/task.rb.tt +52 -0
- data/lib/generators/raif/views_generator.rb +22 -0
- data/lib/raif/configuration.rb +82 -0
- data/lib/raif/default_llms.rb +37 -0
- data/lib/raif/engine.rb +86 -0
- data/lib/raif/errors/action_not_authorized_error.rb +8 -0
- data/lib/raif/errors/anthropic/api_error.rb +10 -0
- data/lib/raif/errors/invalid_config_error.rb +8 -0
- data/lib/raif/errors/invalid_conversation_type_error.rb +8 -0
- data/lib/raif/errors/invalid_user_tool_type_error.rb +8 -0
- data/lib/raif/errors/open_ai/api_error.rb +10 -0
- data/lib/raif/errors/open_ai/json_schema_error.rb +10 -0
- data/lib/raif/errors.rb +9 -0
- data/lib/raif/languages.rb +33 -0
- data/lib/raif/rspec.rb +7 -0
- data/lib/raif/utils/html_to_markdown_converter.rb +7 -0
- data/lib/raif/utils/readable_content_extractor.rb +61 -0
- data/lib/raif/utils.rb +6 -0
- data/lib/raif/version.rb +5 -0
- data/lib/raif.rb +65 -0
- data/lib/tasks/raif_tasks.rake +6 -0
- 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
|