diff --git a/app/jobs/autotitle_conversation_job.rb b/app/jobs/autotitle_conversation_job.rb index 376b4ba7c..c96ea6669 100644 --- a/app/jobs/autotitle_conversation_job.rb +++ b/app/jobs/autotitle_conversation_job.rb @@ -6,23 +6,28 @@ class ConversationNotReady < StandardError; end queue_as :default def perform(conversation_id) - conversation = Conversation.find(conversation_id) - return false if conversation.assistant.api_service.effective_token.blank? # should we use anthropic key if that's all the user has? + @conversation = Conversation.find(conversation_id) + return false if @conversation.assistant.api_service.effective_token.blank? # should we use anthropic key if that's all the user has? - messages = conversation.messages.ordered.limit(4) + messages = @conversation.messages.ordered.limit(4) raise ConversationNotReady if messages.empty? - new_title = Current.set(user: conversation.user) do + new_title = Current.set(user: @conversation.user) do generate_title_for(messages.map(&:content_text).join("\n")) end - conversation.update!(title: new_title) + @conversation.update!(title: new_title) end private def generate_title_for(text) - json_response = ChatCompletionAPI.get_next_response(system_message, [text], response_format: {type: "json_object"}) - json_response["topic"] + ai_backend = @conversation.assistant.api_service.ai_backend.new(@conversation.user, @conversation.assistant) + response = ai_backend.get_oneoff_message( + system_message, + [text], + # response_format: { type: "json_object" }) this causes problems for Groq even though it's supported: https://console.groq.com/docs/api-reference#chat-create + ) + JSON.parse(response).dig("topic") end def system_message @@ -43,7 +48,7 @@ def system_message Your reply (always do JSON): ``` - { topic: "Rails collection counter" } + { "topic": "Rails collection counter" } ``` END end diff --git a/app/jobs/get_next_ai_message_job.rb b/app/jobs/get_next_ai_message_job.rb index 43dcd4be7..74a68b1d9 100644 --- a/app/jobs/get_next_ai_message_job.rb +++ b/app/jobs/get_next_ai_message_job.rb @@ -33,7 +33,7 @@ def perform(user_id, message_id, assistant_id, attempt = 1) response = Current.set(user: @user, message: @message) do ai_backend.new(@conversation.user, @assistant, @conversation, @message) - .get_next_chat_message do |content_chunk| + .stream_next_conversation_message do |content_chunk| @message.content_text += content_chunk if Time.current.to_f - last_sent_at.to_f >= 0.1 @@ -47,7 +47,7 @@ def perform(user_id, message_id, assistant_id, attempt = 1) end end end - @message.content_tool_calls = response # Typically, get_next_chat_message will simply return nil because it executes + @message.content_tool_calls = response # Typically, stream_next_conversation_message will simply return nil because it executes # the content_chunk block to return it's response incrementally. However, tool_call # responses don't make sense to stream because they can't be executed incrementally # so we just return the full tool response message at once. The only time we return diff --git a/app/models/language_model.rb b/app/models/language_model.rb index 947089088..ff6591ff6 100644 --- a/app/models/language_model.rb +++ b/app/models/language_model.rb @@ -35,6 +35,11 @@ def created_by_current_user? user == Current.user end + def supports_tools? + attributes["supports_tools"] && + api_service.name != "Groq" # TODO: Remove this short circuit once I can debug tool use with Groq + end + private def populate_position diff --git a/app/services/ai_backend.rb b/app/services/ai_backend.rb index 05becaa31..42e4fd7d8 100644 --- a/app/services/ai_backend.rb +++ b/app/services/ai_backend.rb @@ -1,50 +1,91 @@ class AIBackend + include Utilities, Tools + attr :client - def initialize(user, assistant, conversation, message) + def initialize(user, assistant, conversation = nil, message = nil) @user = user @assistant = assistant @conversation = conversation - @message = message + @message = message # required for streaming responses + @client_config = {} + @response_handler = nil + end + + def get_oneoff_message(instructions, messages, params = {}) + set_client_config( + instructions: instructions, + messages: preceding_messages(messages), + params: params, + ) + response = @client.send(client_method_name, ** @client_config) + + response.dig("content", 0, "text") || + response.dig("choices", 0, "message", "content") + end + + def stream_next_conversation_message(&chunk_handler) + @stream_response_text = "" + @stream_response_tool_calls = [] + @response_handler = block_given? ? stream_handler(&chunk_handler) : nil + + set_client_config( + instructions: full_instructions, + messages: preceding_conversation_messages, + streaming: true, + ) + + begin + response = @client.send(client_method_name, ** @client_config) + rescue ::Faraday::UnauthorizedError => e + raise configuration_error + end + + if @stream_response_tool_calls.present? + return format_parallel_tool_calls(@stream_response_tool_calls) + elsif @stream_response_text.blank? + raise ::Faraday::ParsingError + end + end + + private + + def client_method_name + raise NotImplementedError end - def self.get_tool_messages_by_calling(tool_calls_response) - tool_calls = deep_json_parse(tool_calls_response) + def configuration_error + raise NotImplementedError + end - # We could parallelize function calling using ruby threads - tool_calls.map do |tool_call| - id = tool_call.dig(:id) - function_name = tool_call.dig(:function, :name) - function_arguments = tool_call.dig(:function, :arguments) + def set_client_config(config) + if config[:streaming] && @response_handler.nil? + raise "You configured streaming: true but did not define @response_handler" + end + end - raise "Unexpected tool call: #{id}, #{function_name}, and #{function_arguments}" if function_name.blank? || function_arguments.nil? + def get_response + raise NotImplementedError + end - function_response = begin - Toolbox.call(function_name, function_arguments) - rescue => e - puts "## Handled error calling tools: #{e.message}" unless Rails.env.test? - puts e.backtrace.join("\n") unless Rails.env.test? + def stream_response + raise NotImplementedError + end - <<~STR.gsub("\n", " ") - An unexpected error occurred (#{e.message}). You were querying information to help you answer a users question. Because this information - is not available at this time, DO NOT MAKE ANY GUESSES as you attempt to answer the users questions. Instead, consider attempting a - different query OR let the user know you attempted to retrieve some information but the website is having difficulties at this time. - STR - end + def preceding_messages(messages = []) + messages.map.with_index do |msg, i| + role = (i % 2).zero? ? "user" : "assistant" { - role: "tool", - content: function_response.to_json, - tool_call_id: id, + role: role, + content: msg } end - rescue => e - puts "## UNHANDLED error calling tools: #{e.message}" - puts e.backtrace.join("\n") - raise ::Faraday::ParsingError end - private + def preceding_conversation_messages + raise NotImplementedError + end def full_instructions s = @assistant.instructions.to_s @@ -57,38 +98,4 @@ def full_instructions s += "\n\nFor the user, the current time is #{DateTime.current.strftime("%-l:%M%P")}; the current date is #{DateTime.current.strftime("%A, %B %-d, %Y")}" s.strip end - - def deep_streaming_merge(hash1, hash2) - merged_hash = hash1.dup - hash2.each do |key, value| - if merged_hash.has_key?(key) && merged_hash[key].is_a?(Hash) && value.is_a?(Hash) - merged_hash[key] = deep_streaming_merge(merged_hash[key], value) - elsif merged_hash.has_key?(key) - merged_hash[key] += value - else - merged_hash[key] = value - end - end - merged_hash - end - - def self.deep_json_parse(obj) - if obj.is_a?(Array) - obj.map { |item| deep_json_parse(item) } - else - converted_hash = {} - obj.each do |key, value| - if value.is_a?(Hash) - converted_hash[key] = deep_json_parse(value) - else - converted_hash[key] = begin - JSON.parse(value) - rescue => e - value - end - end - end - converted_hash - end - end end diff --git a/app/services/ai_backend/anthropic.rb b/app/services/ai_backend/anthropic.rb index 047ff9261..18f004189 100644 --- a/app/services/ai_backend/anthropic.rb +++ b/app/services/ai_backend/anthropic.rb @@ -1,4 +1,6 @@ class AIBackend::Anthropic < AIBackend + include Tools + # Rails system tests don't seem to allow mocking because the server and the # test are in separate processes. # @@ -12,7 +14,7 @@ def self.client end end - def initialize(user, assistant, conversation, message) + def initialize(user, assistant, conversation = nil, message = nil) super(user, assistant, conversation, message) begin raise ::Anthropic::ConfigurationError if assistant.api_service.requires_token? && assistant.api_service.effective_token.blank? @@ -23,10 +25,32 @@ def initialize(user, assistant, conversation, message) end end - def get_next_chat_message(&chunk_received_handler) - stream_response_text = "" + private + + def client_method_name + :messages + end + + def configuration_error + ::Anthropic::ConfigurationError + end + + def set_client_config(config) + super(config) + + @client_config = { + model: @assistant.language_model.provider_name, + system: config[:instructions], + messages: config[:messages], + parameters: { + max_tokens: 2000, # we should really set this dynamically, based on the model, to the max + stream: config[:streaming] && @response_handler || nil, + }.compact.merge(config[:params]&.except(:response_format) || {}) + }.compact + end - response_handler = proc do |intermediate_response, bytesize| + def stream_handler(&chunk_handler) + proc do |intermediate_response, bytesize| chunk = intermediate_response.dig("delta", "text") if (input_tokens = intermediate_response.dig("message", "usage", "input_tokens")) @@ -39,7 +63,7 @@ def get_next_chat_message(&chunk_received_handler) print chunk if Rails.env.development? if chunk - stream_response_text += chunk + @stream_response_text += chunk yield chunk end rescue ::GetNextAIMessageJob::ResponseCancelled => e @@ -50,40 +74,9 @@ def get_next_chat_message(&chunk_received_handler) puts "\nUnhandled error in AIBackend::Anthropic response handler: #{e.message}" puts e.backtrace end - - response_handler = nil unless block_given? - response = nil - - begin - response = @client.messages( - model: @assistant.language_model.provider_name, - system: full_instructions, - messages: preceding_messages, - parameters: { - max_tokens: 2000, # we should really set this dynamically, based on the model, to the max - stream: response_handler, - } - ) - rescue ::Faraday::UnauthorizedError => e - raise ::Anthropic::ConfigurationError - end - - response_text = if response.is_a?(Hash) && response.dig("content") - response.dig("content", 0, "text") - else - response - end - - if response_text.blank? && stream_response_text.blank? - raise ::Faraday::ParsingError - else - response_text - end end - private - - def preceding_messages + def preceding_conversation_messages @conversation.messages.for_conversation_version(@message.version).where("messages.index < ?", @message.index).collect do |message| if @assistant.supports_images? && message.documents.present? diff --git a/app/services/ai_backend/anthropic/tools.rb b/app/services/ai_backend/anthropic/tools.rb new file mode 100644 index 000000000..7ad965bf0 --- /dev/null +++ b/app/services/ai_backend/anthropic/tools.rb @@ -0,0 +1,11 @@ +module AIBackend::Anthropic::Tools + extend ActiveSupport::Concern + + included do + private + + def format_parallel_tool_calls(content_tool_calls) + [] + end + end +end diff --git a/app/services/ai_backend/open_ai.rb b/app/services/ai_backend/open_ai.rb index 1af96b9b7..f00fc713f 100644 --- a/app/services/ai_backend/open_ai.rb +++ b/app/services/ai_backend/open_ai.rb @@ -1,4 +1,6 @@ class AIBackend::OpenAI < AIBackend + include Tools + # Rails system tests don't seem to allow mocking because the server and the # test are in separate processes. # @@ -12,7 +14,7 @@ def self.client end end - def initialize(user, assistant, conversation, message) + def initialize(user, assistant, conversation = nil, message = nil) super(user, assistant, conversation, message) begin raise ::OpenAI::ConfigurationError if assistant.api_service.requires_token? && assistant.api_service.effective_token.blank? @@ -23,37 +25,33 @@ def initialize(user, assistant, conversation, message) end end - def get_next_chat_message(&chunk_handler) - @stream_response_text = "" - @stream_response_tool_calls = [] - response_handler = block_given? ? stream_handler(&chunk_handler) : nil + private - begin - parameters = { - model: @assistant.language_model.provider_name, - messages: system_message + preceding_messages, - stream: response_handler, - max_tokens: 2000, # we should really set this dynamically, based on the model, to the max - stream_options: { include_usage: true } - } - if @assistant.language_model.supports_tools? - parameters[:tools] = Toolbox.tools - end - response = @client.chat(parameters: parameters) - rescue ::Faraday::UnauthorizedError => e - raise ::OpenAI::ConfigurationError - end + def client_method_name + :chat + end - if @stream_response_tool_calls.present? - format_parallel_tool_calls(@stream_response_tool_calls) - elsif @stream_response_text.blank? - raise ::Faraday::ParsingError - end + def configuration_error + ::OpenAI::ConfigurationError end - private + def set_client_config(config) + super(config) + + @client_config = { + parameters: { + model: @assistant.language_model.provider_name, + messages: system_message(config[:instructions]) + config[:messages], + stream: config[:streaming] && @response_handler || nil, + max_tokens: 2000, # we should really set this dynamically, based on the model, to the max + stream_options: config[:streaming] && { include_usage: true } || nil, + response_format: { type: "text" }, + tools: @assistant.language_model.supports_tools? && Toolbox.tools || nil, + }.compact.merge(config[:params] || {}) + } + end - def stream_handler(&chunk_received_handler) + def stream_handler(&chunk_handler) proc do |intermediate_response, bytesize| content_chunk = intermediate_response.dig("choices", 0, "delta", "content") tool_calls_chunk = intermediate_response.dig("choices", 0, "delta", "tool_calls") @@ -87,14 +85,14 @@ def stream_handler(&chunk_received_handler) end end - def system_message + def system_message(content) [{ role: "system", - content: full_instructions + content: content, }] end - def preceding_messages + def preceding_conversation_messages @conversation.messages.for_conversation_version(@message.version).where("messages.index < ?", @message.index).collect do |message| if @assistant.supports_images? && message.documents.present? @@ -120,29 +118,6 @@ def preceding_messages end end - def format_parallel_tool_calls(content_tool_calls) - if content_tool_calls.length > 1 || (calls = content_tool_calls.dig(0, "id"))&.scan("call_").length == 1 - return content_tool_calls - end - - names = find_repeats_and_split(content_tool_calls.dig(0, "function", "name")) - args = content_tool_calls.dig(0, "function", "arguments").split(/(?<=})(?={)/) - - calls.split(/(?=call_)/).map.with_index do |id, i| - { - index: i, - type: "function", - id: id[0...40], - function: { - name: names.fetch(i), - arguments: args.fetch(i), - } - } - end - rescue - {} - end - def find_repeats_and_split(str) (1..str.length).each do |len| substring = str[0, len] diff --git a/app/services/ai_backend/open_ai/tools.rb b/app/services/ai_backend/open_ai/tools.rb new file mode 100644 index 000000000..ef2348a7f --- /dev/null +++ b/app/services/ai_backend/open_ai/tools.rb @@ -0,0 +1,30 @@ +module AIBackend::OpenAI::Tools + extend ActiveSupport::Concern + + included do + private + + def format_parallel_tool_calls(content_tool_calls) + if content_tool_calls.length > 1 || (calls = content_tool_calls.dig(0, "id"))&.scan("call_").length == 1 + return content_tool_calls + end + + names = find_repeats_and_split(content_tool_calls.dig(0, "function", "name")) + args = content_tool_calls.dig(0, "function", "arguments").split(/(?<=})(?={)/) + + calls.split(/(?=call_)/).map.with_index do |id, i| + { + index: i, + type: "function", + id: id[0...40], + function: { + name: names.fetch(i), + arguments: args.fetch(i), + } + } + end + rescue + [] + end + end +end diff --git a/app/services/ai_backend/tools.rb b/app/services/ai_backend/tools.rb new file mode 100644 index 000000000..c654249c5 --- /dev/null +++ b/app/services/ai_backend/tools.rb @@ -0,0 +1,53 @@ +module AIBackend::Tools + extend ActiveSupport::Concern + + class_methods do + def get_tool_messages_by_calling(tool_calls_response) + tool_calls = deep_json_parse(tool_calls_response) + + # We could parallelize function calling using ruby threads + tool_calls.map do |tool_call| + id = tool_call.dig(:id) + function_name = tool_call.dig(:function, :name) + function_arguments = tool_call.dig(:function, :arguments) + + raise "Unexpected tool call: #{id}, #{function_name}, and #{function_arguments}" if function_name.blank? || function_arguments.nil? + + function_response = begin + Toolbox.call(function_name, function_arguments) + rescue => e + puts "## Handled error calling tools: #{e.message}" unless Rails.env.test? + puts e.backtrace.join("\n") unless Rails.env.test? + + <<~STR.gsub("\n", " ") + An unexpected error occurred (#{e.message}). You were querying information to help you answer a users question. Because this information + is not available at this time, DO NOT MAKE ANY GUESSES as you attempt to answer the users questions. Instead, consider attempting a + different query OR let the user know you attempted to retrieve some information but the website is having difficulties at this time. + STR + end + + { + role: "tool", + content: function_response.to_json, + tool_call_id: id, + } + end + rescue => e + puts "## UNHANDLED error calling tools: #{e.message}" + puts e.backtrace.join("\n") + raise ::Faraday::ParsingError + end + end + + included do + private + + def format_parallel_tool_calls(content_tool_calls) + raise NotImplementedError + end + + def parallel_tool_calls(content_tool_calls) + raise NotImplementedError + end + end +end diff --git a/app/services/ai_backend/utilities.rb b/app/services/ai_backend/utilities.rb new file mode 100644 index 000000000..920f65d73 --- /dev/null +++ b/app/services/ai_backend/utilities.rb @@ -0,0 +1,41 @@ +module AIBackend::Utilities + extend ActiveSupport::Concern + + included do + private + + def deep_streaming_merge(hash1, hash2) + merged_hash = hash1.dup + hash2.each do |key, value| + if merged_hash.has_key?(key) && merged_hash[key].is_a?(Hash) && value.is_a?(Hash) + merged_hash[key] = deep_streaming_merge(merged_hash[key], value) + elsif merged_hash.has_key?(key) + merged_hash[key] += value + else + merged_hash[key] = value + end + end + merged_hash + end + + def self.deep_json_parse(obj) + if obj.is_a?(Array) + obj.map { |item| deep_json_parse(item) } + else + converted_hash = {} + obj.each do |key, value| + if value.is_a?(Hash) + converted_hash[key] = deep_json_parse(value) + else + converted_hash[key] = begin + JSON.parse(value) + rescue => e + value + end + end + end + converted_hash + end + end + end +end diff --git a/app/services/chat_completion_api.rb b/app/services/chat_completion_api.rb deleted file mode 100644 index f926ab615..000000000 --- a/app/services/chat_completion_api.rb +++ /dev/null @@ -1,130 +0,0 @@ -class ChatCompletionAPI - - # This is a lightweight wrapper around the OpenAI::Client gem that hides away a bunch of the complexity. - # - # ChatCompletionAPI.get_next_response("You are a comedian", ["Tell me a joke"], model: "gpt-4") - # - - def self.get_next_response(system_message, chat_messages, params = {}) - # docs for this format: https://platform.openai.com/docs/api-reference/chat - - message_payload = [{ - role: "system", - content: system_message - }] - - chat_messages.each_with_index do |msg, i| - role = (i % 2).zero? ? "user" : "assistant" - - message_payload << { - role: role, - content: msg - } - end - - response = call_api({messages: message_payload}.merge(params)) - end - - - private - - def self.default_params - { - model: "gpt-3.5-turbo-1106", - max_tokens: 500, # a sensible default - n: 1, - response_format: { "type": "text" }, # or json_object - } - end - - def self.call_api(params) - params = default_params.deep_symbolize_keys.merge(params.symbolize_keys) - - verify_params!(params) - verify_token_count!(params) - - response = formatted_api_response(params) - - if params[:response_format]&.dig(:type) == "json_object" - JSON.parse(response) - else - response - end - end - - def self.formatted_api_response(params) - if Rails.env.test? - raise "In your test you need to wrap with: ChatCompletionAPI.stub :formatted_api_response, 'value' do; end" - end - - client = OpenAI::Client.new( - access_token: Current.user&.api_services.openai.with_token.first&.effective_token, - request_timeout: 240, - ) - - # We are streaming the response even though we queue it all up before returning because this avoids some timeouts with the - # OpenAI API. If we disable streaming, then requests with really long system prompts and messages struggle to return the - # full response before OpenAI kills it. - - response = "" - - client.chat(parameters: params.merge(stream: proc { |chunk, _bytesize| - finished_reason = chunk&.dig("choices", 0, "finish_reason") - - if !finished_reason - response += chunk&.dig("choices", 0, "delta", "content")&.to_s - else - raise finished_reason unless finished_reason == "stop" - end - })) - - response - end - - def self.verify_params!(params) - if response_format = params[:response_format] - if !response_format.is_a?(Hash) || !response_format.dig(:type)&.in?(%w{ json_object text }) - raise "Your response_format is invalid. e.g. response_format: { 'type': 'json_object' }" - end - end - end - - def self.verify_token_count!(params) - count = token_count(params[:messages].to_json, model: params[:model]) + params[:max_tokens] - limit = model_token_limit(params[:model]) - - if count >= limit - raise "Too many tokens. Using #{count} tokens (with #{params[:max_tokens]} of those being in the response), but the model #{params[:model]} has a limit of #{limit} tokens." - end - end - - def self.token_count(string, model: nil) - tokenize(string, model: model).length - end - - def self.tokenize(string, model: nil) - encoding = Tiktoken.encoding_for_model(model) - encoding.encode(string) - end - - def self.model_token_limit(name) - # Docs for available models: https://platform.openai.com/docs/models/gpt-4-and-gpt-4-turbo - - { - "gpt-4-turbo-2024-04-09" => 128000, - "gpt-4-0125-preview" => 128000, - "gpt-4-1106-preview" => 128000, - "gpt-4-vision-preview" => 128000, - "gpt-4-1106-vision-preview" => 128000, - "gpt-4" => 8192, - "gpt-4-32k" => 32768, - "gpt-4-0613" => 8192, - "gpt-4-32k-0613" => 32768, - "gpt-3.5-turbo-0125" => 16385, - "gpt-3.5-turbo-1106" => 16385, - "gpt-3.5-turbo" => 4096, - "gpt-3.5-turbo-16k" => 16385, - "gpt-3.5-turbo-instruct" => 4096 - }[name] - end -end diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index c62a15626..fb5f8f6ab 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -145,7 +145,7 @@ def assert_did_not_scroll(selector = "section #messages-container") yield new_scroll = nil - assert_true "The #{selector} should not have scrolled but position is #{new_scroll} rather than #{scroll_position_first_element_relative_viewport}" do + assert_true "The #{selector} should not have scrolled but position changed from #{scroll_position_first_element_relative_viewport}" do new_scroll = page.evaluate_script("document.querySelector('#{selector}').children[1].getBoundingClientRect().top") scroll_position_first_element_relative_viewport == new_scroll end diff --git a/test/fixtures/assistants.yml b/test/fixtures/assistants.yml index f9a378037..4892f1639 100644 --- a/test/fixtures/assistants.yml +++ b/test/fixtures/assistants.yml @@ -22,6 +22,14 @@ keith_claude3: instructions: tools: [] +keith_claude35: + user: keith + language_model: claude_3_5_sonnet_20240620 + name: Claude 3.5 Sonnet + description: Claude 3.5, Sonnet version + instructions: + tools: [] + keith_gpt3: user: keith language_model: gpt_3_5_turbo diff --git a/test/jobs/autotitle_conversation_job_test.rb b/test/jobs/autotitle_conversation_job_test.rb index 0c25dcb80..2d0a716af 100644 --- a/test/jobs/autotitle_conversation_job_test.rb +++ b/test/jobs/autotitle_conversation_job_test.rb @@ -4,7 +4,7 @@ class AutotitleConversationJobTest < ActiveJob::TestCase test "sets conversation title automatically when there are two messages" do conversation = conversations(:greeting) - ChatCompletionAPI.stub :get_next_response, {"topic" => "Hear me"} do + TestClient::OpenAI.stub :text, "{\"topic\":\"Hear me\"}" do AutotitleConversationJob.perform_now(conversation.id) end @@ -15,7 +15,7 @@ class AutotitleConversationJobTest < ActiveJob::TestCase conversation = conversations(:javascript) conversation.latest_message_for_version(:latest).destroy! - ChatCompletionAPI.stub :get_next_response, {"topic" => "Javascript popState"} do + TestClient::OpenAI.stub :text, "{\"topic\":\"Javascript popState\"}" do AutotitleConversationJob.perform_now(conversation.id) end @@ -27,7 +27,7 @@ class AutotitleConversationJobTest < ActiveJob::TestCase conversation.update!(updated_at: Time.current) # update is what triggers the callback assert_nothing_raised do # confirms the exception did not raise outside the job - ChatCompletionAPI.stub :get_next_response, {"topic" => "This will not be set"} do + TestClient::OpenAI.stub :text, "{\"topic\":\"Javascript popState\"}" do AutotitleConversationJob.perform_now(conversation.id) end end diff --git a/test/jobs/get_next_ai_message_job_openai_test.rb b/test/jobs/get_next_ai_message_job_openai_test.rb index 5ecafbf04..aac8fe17c 100644 --- a/test/jobs/get_next_ai_message_job_openai_test.rb +++ b/test/jobs/get_next_ai_message_job_openai_test.rb @@ -4,7 +4,9 @@ class GetNextAIMessageJobOpenaiTest < ActiveJob::TestCase setup do @conversation = conversations(:greeting) @user = @conversation.user - @conversation.messages.create! role: :user, content_text: "Still there?", assistant: @conversation.assistant + @assistant = @conversation.assistant + @conversation.messages.create! role: :user, content_text: "Still there?", assistant: @assistant + @assistant.language_model.update!(supports_tools: false) # this will change the TestClient response so we want to be selective about this @message = @conversation.latest_message_for_version(:latest) @test_client = TestClient::OpenAI.new(access_token: "abc") end @@ -12,9 +14,7 @@ class GetNextAIMessageJobOpenaiTest < ActiveJob::TestCase test "populates the latest message from the assistant" do assert_no_difference "@conversation.messages.reload.length" do TestClient::OpenAI.stub :text, "Hello" do - TestClient::OpenAI.stub :api_response, -> { TestClient::OpenAI.api_text_response } do - assert GetNextAIMessageJob.perform_now(@user.id, @message.id, @conversation.assistant.id) - end + assert GetNextAIMessageJob.perform_now(@user.id, @message.id, @assistant.id) end end @@ -22,12 +22,12 @@ class GetNextAIMessageJobOpenaiTest < ActiveJob::TestCase end test "populates a tool response call from the assistant and creates additional tool messages" do + @assistant.language_model.update!(supports_tools: true) + assert_difference "@conversation.messages.reload.length", 2 do TestClient::OpenAI.stub :function, "helloworld_hi" do TestClient::OpenAI.stub :arguments, {:name=>"Keith"} do - TestClient::OpenAI.stub :api_response, TestClient::OpenAI.api_function_response do - assert GetNextAIMessageJob.perform_now(@user.id, @message.id, @conversation.assistant.id) - end + assert GetNextAIMessageJob.perform_now(@user.id, @message.id, @assistant.id) end end end @@ -57,7 +57,7 @@ class GetNextAIMessageJobOpenaiTest < ActiveJob::TestCase end test "returns early if the message id was invalid" do - refute GetNextAIMessageJob.perform_now(@user.id, 0, @conversation.assistant.id) + refute GetNextAIMessageJob.perform_now(@user.id, 0, @assistant.id) end test "returns early if the assistant id was invalid" do @@ -66,28 +66,26 @@ class GetNextAIMessageJobOpenaiTest < ActiveJob::TestCase test "returns early if the message was already generated" do @message.update!(content_text: "Hello") - refute GetNextAIMessageJob.perform_now(@user.id, @message.id, @conversation.assistant.id) + refute GetNextAIMessageJob.perform_now(@user.id, @message.id, @assistant.id) end test "returns early if the user has replied after this" do - @conversation.messages.create! role: :user, content_text: "Ignore that, new question:", assistant: @conversation.assistant - refute GetNextAIMessageJob.perform_now(@user.id, @message.id, @conversation.assistant.id) + @conversation.messages.create! role: :user, content_text: "Ignore that, new question:", assistant: @assistant + refute GetNextAIMessageJob.perform_now(@user.id, @message.id, @assistant.id) end test "when openai key is blank, a nice error message is displayed" do - api_service = @conversation.assistant.language_model.api_service + api_service = @assistant.language_model.api_service api_service.update!(token: "") - assert GetNextAIMessageJob.perform_now(@user.id, @message.id, @conversation.assistant.id) + assert GetNextAIMessageJob.perform_now(@user.id, @message.id, @assistant.id) assert_includes @conversation.latest_message_for_version(:latest).content_text, "need to enter a valid API key for OpenAI" end test "when API response key is missing, a nice error message is displayed" do TestClient::OpenAI.stub :text, "" do - TestClient::OpenAI.stub :api_response, -> { TestClient::OpenAI.api_text_response } do - assert GetNextAIMessageJob.perform_now(@user.id, @message.id, @conversation.assistant.id) - assert_includes @conversation.latest_message_for_version(:latest).content_text, "a blank response" - end + assert GetNextAIMessageJob.perform_now(@user.id, @message.id, @assistant.id) + assert_includes @conversation.latest_message_for_version(:latest).content_text, "a blank response" end end end diff --git a/test/jobs/get_next_ai_message_job_test.rb b/test/jobs/get_next_ai_message_job_test.rb index fddb818a1..6656708b7 100644 --- a/test/jobs/get_next_ai_message_job_test.rb +++ b/test/jobs/get_next_ai_message_job_test.rb @@ -4,15 +4,17 @@ class GetNextAIMessageJobOpenaiTest < ActiveJob::TestCase setup do @conversation = conversations(:greeting) @user = @conversation.user - @conversation.messages.create! role: :user, content_text: "Are you still there?", assistant: @conversation.assistant + @assistant = @conversation.assistant + @assistant.language_model.update!(supports_tools: false) + @conversation.messages.create! role: :user, content_text: "Are you still there?", assistant: @assistant @message = @conversation.latest_message_for_version(:latest) @test_client = TestClient::OpenAI.new(access_token: "abc") end test "if a new message is created BEFORE job starts, it does not process" do - @conversation.messages.create! role: :user, content_text: "You there?", assistant: @conversation.assistant + @conversation.messages.create! role: :user, content_text: "You there?", assistant: @assistant - refute GetNextAIMessageJob.perform_now(@user.id, @message.id, @conversation.assistant.id) + refute GetNextAIMessageJob.perform_now(@user.id, @message.id, @assistant.id) assert @message.content_text.blank? assert_nil @message.cancelled_at end @@ -20,7 +22,7 @@ class GetNextAIMessageJobOpenaiTest < ActiveJob::TestCase test "if the cancel streaming button is clicked BEFORE job starts, it does not process" do @message.cancelled! - refute GetNextAIMessageJob.perform_now(@user.id, @message.id, @conversation.assistant.id) + refute GetNextAIMessageJob.perform_now(@user.id, @message.id, @assistant.id) assert @message.content_text.blank? assert_not_nil @message.cancelled_at end @@ -34,12 +36,9 @@ class GetNextAIMessageJobOpenaiTest < ActiveJob::TestCase job = GetNextAIMessageJob.new job.stub(:message_cancelled?, -> { false_on_first_run += 1; false_on_first_run != 1 }) do TestClient::OpenAI.stub :text, "Hello" do - TestClient::OpenAI.stub :api_response, -> { TestClient::OpenAI.api_text_response }do - - assert_changes "@message.content_text", from: nil, to: "Hello" do - assert_changes "@message.reload.cancelled_at", from: nil do - assert job.perform(@user.id, @message.id, @conversation.assistant.id) - end + assert_changes "@message.content_text", from: nil, to: "Hello" do + assert_changes "@message.reload.cancelled_at", from: nil do + assert job.perform(@user.id, @message.id, @assistant.id) end end end diff --git a/test/models/conversation_test.rb b/test/models/conversation_test.rb index 54bc3666b..f382e7c79 100644 --- a/test/models/conversation_test.rb +++ b/test/models/conversation_test.rb @@ -76,24 +76,21 @@ class ConversationTest < ActiveSupport::TestCase end test "the title of a conversation is automatically set when the second message is created by the job" do - perform_enqueued_jobs do - ChatCompletionAPI.stub :get_next_response, {"topic" => "Hear me"} do - TestClient::OpenAI.stub :text, "Hello" do - TestClient::OpenAI.stub :api_response, -> { TestClient::OpenAI.api_text_response } do + assistants(:samantha).language_model.update!(supports_tools: false) - conversation = users(:keith).conversations.create!(assistant: assistants(:samantha)) - assert_nil conversation.title + perform_enqueued_jobs do + TestClient::OpenAI.stub :text, "{\"topic\":\"Hear me\"}" do + conversation = users(:keith).conversations.create!(assistant: assistants(:samantha)) + assert_nil conversation.title - conversation.messages.create!(assistant: conversation.assistant, role: :user, content_text: "Can you hear me?") + conversation.messages.create!(assistant: conversation.assistant, role: :user, content_text: "Can you hear me?") - latest_message = conversation.latest_message_for_version(:latest) - assert latest_message.assistant? + latest_message = conversation.latest_message_for_version(:latest) + assert latest_message.assistant? - GetNextAIMessageJob.perform_now(users(:keith).id, latest_message.id, assistants(:samantha).id) + GetNextAIMessageJob.perform_now(users(:keith).id, latest_message.id, assistants(:samantha).id) - assert_equal "Hear me", conversation.reload.title - end - end + assert_equal "Hear me", conversation.reload.title end end end diff --git a/test/services/ai_backend/anthropic/tools_test.rb b/test/services/ai_backend/anthropic/tools_test.rb new file mode 100644 index 000000000..d61ce7243 --- /dev/null +++ b/test/services/ai_backend/anthropic/tools_test.rb @@ -0,0 +1,14 @@ +require "test_helper" + +class AIBackend::Anthropic::ToolsTest < ActiveSupport::TestCase + setup do + @conversation = conversations(:attachments) + @anthropic = AIBackend::Anthropic.new(users(:keith), + assistants(:keith_claude3), + @conversation, + @conversation.latest_message_for_version(:latest) + ) + @test_client = TestClient::Anthropic.new(access_token: "abc") + end + +end diff --git a/test/services/ai_backend/anthropic_test.rb b/test/services/ai_backend/anthropic_test.rb index 30db7d094..7791ae20d 100644 --- a/test/services/ai_backend/anthropic_test.rb +++ b/test/services/ai_backend/anthropic_test.rb @@ -2,49 +2,57 @@ class AIBackend::AnthropicTest < ActiveSupport::TestCase setup do - @conversation = conversations(:attachments) - @anthropic = AIBackend::Anthropic.new(users(:keith), - assistants(:keith_claude3), + @conversation = conversations(:hello_claude) + @assistant = assistants(:keith_claude35) + @assistant.language_model.update!(supports_tools: false) # this will change the TestClient response so we want to be selective about this + @anthropic = AIBackend::Anthropic.new( + users(:keith), + @assistant, @conversation, @conversation.latest_message_for_version(:latest) ) - @test_client = TestClient::Anthropic.new(access_token: "abc") + TestClient::Anthropic.new(access_token: "abc") end test "initializing client works" do assert @anthropic.client.present? end - test "get_next_chat_message works" do - assert_equal "https://api.anthropic.com/", @anthropic.client.uri_base - streamed_text = @test_client.messages(model: "claude_3_opus_20240229", system: "You are a helpful assistant") + test "stream_next_conversation_message works to stream text and uses model from assistant" do + assert_not_equal @assistant, @conversation.assistant, "Should force this next message to use a different assistant so these don't match" - assert_equal "Hello this is model claude_3_opus_20240229 with instruction \"You are a helpful assistant\"! How can I assist you today?", streamed_text + TestClient::Anthropic.stub :text, nil do # this forces it to fall back to default text + streamed_text = "" + @anthropic.stream_next_conversation_message { |chunk| streamed_text += chunk } + expected_start = "Hello this is model claude-3-5-sonnet-20240620 with instruction \"Note these additional items that you've been told and remembered:\\n\\nHe lives in Austin, Texas\\n\\nFor the user, the current time" + expected_end = "\"! How can I assist you today?" + assert streamed_text.start_with?(expected_start) + assert streamed_text.end_with?(expected_end) + end end - test "preceding_messages constructs a proper response and pivots on images" do - preceding_messages = @anthropic.send(:preceding_messages) + test "preceding_conversation_messages constructs a proper response and pivots on images" do + preceding_conversation_messages = @anthropic.send(:preceding_conversation_messages) - assert_equal @conversation.messages.length-1, preceding_messages.length + assert_equal @conversation.messages.length-1, preceding_conversation_messages.length @conversation.messages.ordered.each_with_index do |message, i| next if @conversation.messages.length == i+1 if message.documents.present? - assert_instance_of Array, preceding_messages[i][:content] - assert_equal message.documents.length+1, preceding_messages[i][:content].length + assert_instance_of Array, preceding_conversation_messages[i][:content] + assert_equal message.documents.length+1, preceding_conversation_messages[i][:content].length else - assert_equal preceding_messages[i][:content], message.content_text + assert_equal preceding_conversation_messages[i][:content], message.content_text end end end - test "preceding_messages only considers messages up to the assistant message being generated" do - @anthropic = AIBackend::Anthropic.new(users(:keith), assistants(:samantha), @conversation, messages(:yes_i_can)) - - preceding_messages = @anthropic.send(:preceding_messages) + test "preceding_conversation_messages only considers messages on the intended conversation version and includes the correct names" do + # TODO + end - assert_equal 1, preceding_messages.length - assert_equal preceding_messages[0][:content], messages(:can_you_hear).content_text + test "preceding_conversation_messages includes the appropriate tool details" do + # TODO end end diff --git a/test/services/ai_backend/open_ai/tools_test.rb b/test/services/ai_backend/open_ai/tools_test.rb new file mode 100644 index 000000000..76737920b --- /dev/null +++ b/test/services/ai_backend/open_ai/tools_test.rb @@ -0,0 +1,80 @@ +require "test_helper" + +class AIBackend::OpenAI::ToolsTest < ActiveSupport::TestCase + setup do + @conversation = conversations(:attachments) + @assistant = assistants(:keith_gpt4) + @assistant.language_model.update!(supports_tools: false) # this will change the TestClient response so we want to be selective about this + @openai = AIBackend::OpenAI.new( + users(:keith), + @assistant, + @conversation, + @conversation.latest_message_for_version(:latest) + ) + TestClient::OpenAI.new(access_token: "abc") + end + + test "tools only passed when supported by the language model" do + @assistant.language_model.update!(supports_tools: true) + function = "openmeteo_get_current_and_todays_weather" + streamed_text = "" + + TestClient::OpenAI.stub :function, function do + @openai.stream_next_conversation_message { |chunk| streamed_text += chunk } + assert_includes TestClient::OpenAI.parameters.keys, :tools + end + end + + test "tools not passed when not supported by the language model" do + streamed_text = "" + + TestClient::OpenAI.stub :text, nil do + @openai.stream_next_conversation_message { |chunk| streamed_text += chunk } + assert_not_includes TestClient::OpenAI.parameters.keys, :tools + end + end + + test "stream_next_conversation_message works to get a function call" do + @assistant.language_model.update!(supports_tools: true) + function = "openmeteo_get_current_and_todays_weather" + + TestClient::OpenAI.stub :function, function do + function_call = @openai.stream_next_conversation_message { |chunk| streamed_text += chunk } + assert_equal function, function_call.dig(0, "function", "name") + end + end + + test "stream_next_conversation_message works to get a parallel function call CORRECTLY formatted" do + @assistant.language_model.update!(supports_tools: true) + function = "openmeteo_get_current_and_todays_weather" + + TestClient::OpenAI.stub :function, function do + TestClient::OpenAI.stub :num_tool_calls, 2 do + function_calls = @openai.stream_next_conversation_message { |chunk| streamed_text += chunk } + + assert_equal 2, function_calls.length + assert_equal [0,1], function_calls.map { |f| f["index"] } + assert_equal [function, function], function_calls.map { |f| f["function"]["name"] } + end + end + end + + test "stream_next_conversation_message works to get a parallel function call INCORRECTLY formatted" do + @assistant.language_model.update!(supports_tools: true) + function = "openmeteo_get_current_and_todays_weather" + arguments = {:city=>"Austin", :state=>"TX", :country=>"US"}.to_json + + TestClient::OpenAI.stub :function, function+function do + TestClient::OpenAI.stub :arguments, arguments+arguments do + TestClient::OpenAI.stub :id, "call_abccall_def" do + function_calls = @openai.stream_next_conversation_message { |chunk| streamed_text += chunk } + + assert_equal 2, function_calls.length + assert_equal [0,1], function_calls.map { |f| f[:index] } + assert_equal ["call_abc", "call_def"], function_calls.map { |f| f[:id] } + assert_equal [function, function], function_calls.map { |f| f[:function][:name] } + end + end + end + end +end diff --git a/test/services/ai_backend/open_ai_test.rb b/test/services/ai_backend/open_ai_test.rb index e48045829..378f84a3f 100644 --- a/test/services/ai_backend/open_ai_test.rb +++ b/test/services/ai_backend/open_ai_test.rb @@ -4,33 +4,50 @@ class AIBackend::OpenAITest < ActiveSupport::TestCase setup do @conversation = conversations(:attachments) @assistant = assistants(:keith_gpt4) + @assistant.language_model.update!(supports_tools: false) # this will change the TestClient response so we want to be selective about this @openai = AIBackend::OpenAI.new( users(:keith), @assistant, @conversation, @conversation.latest_message_for_version(:latest) ) - @test_client = TestClient::OpenAI.new(access_token: "abc") + TestClient::OpenAI.new(access_token: "abc") end test "initializing client works" do assert @openai.client.present? end - test "get_next_chat_message works to stream text and uses model from assistant" do - assert_not_equal @assistant, @conversation.assistant, - "We were supposed to forcing this next message to use a different assistant so these should not match" - + test "openai url is properly set" do assert_equal "https://api.openai.com/", @openai.client.uri_base + end + + test "get_oneoff_message responds with a reply" do + TestClient::OpenAI.stub :text, "Yes, I can hear you." do + response = @openai.get_oneoff_message("I am a helpful assistant.", ["Can you hear me?"]) + assert_equal "Yes, I can hear you.", response + assert_equal({:type=>"text"}, TestClient::OpenAI.parameters[:response_format]) + end + end + + test "get_oneoff_message with response_format of json returns a hash" do + TestClient::OpenAI.stub :text, "{\"response\":\"yes\"}" do + response = @openai.get_oneoff_message("Reply with the JSON { response: 'yes' }", ["Give me the reply."], response_format: { type: "json_object" } ) + assert_equal({"response"=>"yes"}, JSON.parse(response)) + assert_equal({:type=>"json_object"}, TestClient::OpenAI.parameters[:response_format]) + end + end + + test "stream_next_conversation_message works to stream text and uses model from assistant" do + assert_not_equal @assistant, @conversation.assistant, "Should force this next message to use a different assistant so these don't match" + TestClient::OpenAI.stub :text, nil do # this forces it to fall back to default text - TestClient::OpenAI.stub :api_response, -> { TestClient::OpenAI.api_text_response }do - streamed_text = "" - @openai.get_next_chat_message { |chunk| streamed_text += chunk } - expected_start = "Hello this is model gpt-4o with instruction \"Note these additional items that you've been told and remembered:\\n\\nHe lives in Austin, Texas\\n\\nFor the user, the current time" - expected_end = "\"! How can I assist you today?" - assert streamed_text.start_with?(expected_start) - assert streamed_text.end_with?(expected_end) - end + streamed_text = "" + @openai.stream_next_conversation_message { |chunk| streamed_text += chunk } + expected_start = "Hello this is model gpt-4o with instruction \"Note these additional items that you've been told and remembered:\\n\\nHe lives in Austin, Texas\\n\\nFor the user, the current time" + expected_end = "\"! How can I assist you today?" + assert streamed_text.start_with?(expected_start) + assert streamed_text.end_with?(expected_end) end end @@ -43,25 +60,23 @@ class AIBackend::OpenAITest < ActiveSupport::TestCase assert_equal [tool_message], AIBackend::OpenAI.get_tool_messages_by_calling(messages(:weather_tool_call).content_tool_calls) end - test "tools_passed_when_supported_by_the_language_model" do + test "tools only passed when supported by the language model" do + @assistant.language_model.update!(supports_tools: true) + function = "openmeteo_get_current_and_todays_weather" streamed_text = "" - TestClient::OpenAI.stub :text, nil do - TestClient::OpenAI.stub :api_response, -> { TestClient::OpenAI.api_text_response } do - @openai.get_next_chat_message { |chunk| streamed_text += chunk } - assert_equal [:model, :messages, :stream, :max_tokens, :stream_options, :tools], TestClient::OpenAI.parameters.keys - end + + TestClient::OpenAI.stub :function, function do + @openai.stream_next_conversation_message { |chunk| streamed_text += chunk } + assert_includes TestClient::OpenAI.parameters.keys, :tools end end - test "tools_not_passed_when_not_supported_by_the_language_model" do - language_models(:gpt_4o).update!(supports_tools: false) - @assistant.reload + test "tools not passed when not supported by the language model" do streamed_text = "" + TestClient::OpenAI.stub :text, nil do - TestClient::OpenAI.stub :api_response, -> { TestClient::OpenAI.api_text_response } do - @openai.get_next_chat_message { |chunk| streamed_text += chunk } - assert_equal [:model, :messages, :stream, :max_tokens, :stream_options], TestClient::OpenAI.parameters.keys - end + @openai.stream_next_conversation_message { |chunk| streamed_text += chunk } + assert_not_includes TestClient::OpenAI.parameters.keys, :tools end end @@ -87,23 +102,23 @@ class AIBackend::OpenAITest < ActiveSupport::TestCase assert msg[:content].starts_with?('"An unexpected error occurred') end - test "get_next_chat_message works to get a function call" do + test "stream_next_conversation_message works to get a function call" do + @assistant.language_model.update!(supports_tools: true) function = "openmeteo_get_current_and_todays_weather" TestClient::OpenAI.stub :function, function do - TestClient::OpenAI.stub :api_response, TestClient::OpenAI.api_function_response do - function_call = @openai.get_next_chat_message { |chunk| streamed_text += chunk } - assert_equal function, function_call.dig(0, "function", "name") - end + function_call = @openai.stream_next_conversation_message { |chunk| streamed_text += chunk } + assert_equal function, function_call.dig(0, "function", "name") end end - test "get_next_chat_message works to get a parallel function call CORRECTLY formatted" do + test "stream_next_conversation_message works to get a parallel function call CORRECTLY formatted" do + @assistant.language_model.update!(supports_tools: true) function = "openmeteo_get_current_and_todays_weather" TestClient::OpenAI.stub :function, function do - TestClient::OpenAI.stub :api_response, TestClient::OpenAI.api_function_response(2) do - function_calls = @openai.get_next_chat_message { |chunk| streamed_text += chunk } + TestClient::OpenAI.stub :num_tool_calls, 2 do + function_calls = @openai.stream_next_conversation_message { |chunk| streamed_text += chunk } assert_equal 2, function_calls.length assert_equal [0,1], function_calls.map { |f| f["index"] } @@ -112,44 +127,43 @@ class AIBackend::OpenAITest < ActiveSupport::TestCase end end - test "get_next_chat_message works to get a parallel function call INCORRECTLY formatted" do + test "stream_next_conversation_message works to get a parallel function call INCORRECTLY formatted" do + @assistant.language_model.update!(supports_tools: true) function = "openmeteo_get_current_and_todays_weather" arguments = {:city=>"Austin", :state=>"TX", :country=>"US"}.to_json TestClient::OpenAI.stub :function, function+function do TestClient::OpenAI.stub :arguments, arguments+arguments do TestClient::OpenAI.stub :id, "call_abccall_def" do - TestClient::OpenAI.stub :api_response, TestClient::OpenAI.api_function_response do - function_calls = @openai.get_next_chat_message { |chunk| streamed_text += chunk } - - assert_equal 2, function_calls.length - assert_equal [0,1], function_calls.map { |f| f[:index] } - assert_equal ["call_abc", "call_def"], function_calls.map { |f| f[:id] } - assert_equal [function, function], function_calls.map { |f| f[:function][:name] } - end + function_calls = @openai.stream_next_conversation_message { |chunk| streamed_text += chunk } + + assert_equal 2, function_calls.length + assert_equal [0,1], function_calls.map { |f| f[:index] } + assert_equal ["call_abc", "call_def"], function_calls.map { |f| f[:id] } + assert_equal [function, function], function_calls.map { |f| f[:function][:name] } end end end end - test "preceding_messages constructs a proper response and pivots on images" do - preceding_messages = @openai.send(:preceding_messages) + test "preceding_conversation_messages constructs a proper response and pivots on images" do + preceding_conversation_messages = @openai.send(:preceding_conversation_messages) - assert_equal @conversation.messages.length-1, preceding_messages.length + assert_equal @conversation.messages.length-1, preceding_conversation_messages.length @conversation.messages.ordered.each_with_index do |message, i| next if @conversation.messages.length == i+1 if message.documents.present? - assert_instance_of Array, preceding_messages[i][:content] - assert_equal message.documents.length+1, preceding_messages[i][:content].length + assert_instance_of Array, preceding_conversation_messages[i][:content] + assert_equal message.documents.length+1, preceding_conversation_messages[i][:content].length else - assert_equal preceding_messages[i][:content], message.content_text + assert_equal preceding_conversation_messages[i][:content], message.content_text end end end - test "preceding_messages only considers messages on the intended conversation version and includes the correct names" do + test "preceding_conversation_messages only considers messages on the intended conversation version and includes the correct names" do message = messages(:message3_v1) conversation = message.conversation assistant = message.assistant @@ -157,15 +171,15 @@ class AIBackend::OpenAITest < ActiveSupport::TestCase version = message.version @openai = AIBackend::OpenAI.new(user, assistant, conversation, message) - preceding_messages = @openai.send(:preceding_messages) + preceding_conversation_messages = @openai.send(:preceding_conversation_messages) convo_messages = conversation.messages.for_conversation_version(version).where("messages.index < ?", message.index) - assert_equal convo_messages.map(&:content_text), preceding_messages.map { |m| m[:content] } - assert_equal user.first_name, preceding_messages.first[:name] - assert_equal assistant.name, preceding_messages.second[:name] + assert_equal convo_messages.map(&:content_text), preceding_conversation_messages.map { |m| m[:content] } + assert_equal user.first_name, preceding_conversation_messages.first[:name] + assert_equal assistant.name, preceding_conversation_messages.second[:name] end - test "preceding_messages includes the appropriate tool details" do + test "preceding_conversation_messages includes the appropriate tool details" do message = messages(:weather_explained) conversation = message.conversation assistant = message.assistant @@ -173,7 +187,7 @@ class AIBackend::OpenAITest < ActiveSupport::TestCase version = message.version @openai = AIBackend::OpenAI.new(user, assistant, conversation, message) - messages = @openai.send(:preceding_messages) + messages = @openai.send(:preceding_conversation_messages) m1 = {:role=>"user", :name=>"Keith", :content=>"What is the weather in Austin?"} m2 = {:role=>"assistant", :name=>"Samantha", :tool_calls=>[{:id=>"abc123", :type=>"function", :index=>0, :function=>{:name=>"helloworld_hi", :arguments=>{:name=>"World"}}}]} diff --git a/test/services/ai_backend/tools_test.rb b/test/services/ai_backend/tools_test.rb new file mode 100644 index 000000000..8ee3a5b10 --- /dev/null +++ b/test/services/ai_backend/tools_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +class AIBackend::ToolsTest < ActiveSupport::TestCase + test "get_tool_messages_by_calling properly executes tools" do + tool_message = { + role: "tool", + content: "\"Hello, World!\"", + tool_call_id: "abc123", + } + assert_equal [tool_message], AIBackend.get_tool_messages_by_calling(messages(:weather_tool_call).content_tool_calls) + end + + test "get_tool_messages_by_calling gracefully handles a failure within a function call" do + tool_calls = messages(:weather_tool_call).content_tool_calls + tool_calls[0][:function][:name] = "helloworld_bad" + tool_calls[0][:function][:arguments].delete(:name) + + msg = AIBackend::OpenAI.get_tool_messages_by_calling(tool_calls).first + assert_equal "tool", msg[:role] + assert_equal "abc123", msg[:tool_call_id] + assert msg[:content].starts_with?('"An unexpected error occurred') + end + + test "get_tool_messages_by_calling gracefully handles calling an invalid function" do + tool_calls = messages(:weather_tool_call).content_tool_calls + tool_calls[0][:function][:name] = "helloworld_nonexistent" + tool_calls[0][:function][:arguments].delete(:name) + + msg = AIBackend::OpenAI.get_tool_messages_by_calling(tool_calls).first + assert_equal "tool", msg[:role] + assert_equal "abc123", msg[:tool_call_id] + assert msg[:content].starts_with?('"An unexpected error occurred') + end +end diff --git a/test/services/chat_completion_api_test.rb b/test/services/chat_completion_api_test.rb deleted file mode 100644 index 10d0c237c..000000000 --- a/test/services/chat_completion_api_test.rb +++ /dev/null @@ -1,31 +0,0 @@ -require "test_helper" - -class ChatCompletionAPITest < ActiveSupport::TestCase - test "get_next_response responds with a reply" do - Current.user = users(:keith) - - response = ChatCompletionAPI.stub :formatted_api_response, "No, I cannot hear you. I am an AI text-based assistant." do - ChatCompletionAPI.get_next_response("I am a helpful assistant.", ["Can you hear me?"]) - end - assert_equal "No, I cannot hear you. I am an AI text-based assistant.", response - end - - test "get_next_response with an invalid response_format raises" do - Current.user = users(:keith) - error = assert_raises do - ChatCompletionAPI.get_next_response("I am a helpful assistant.", ["Can you hear me?"], response_format: "json_object" ) - end - assert_match /response_format is invalid/, error.message - end - - test "get_next_response with response_format of json returns a hash" do - Current.user = users(:keith) - - ChatCompletionAPI.stub :formatted_api_response, "{\"response\":\"yes\"}" do - response = assert_nothing_raised do - ChatCompletionAPI.get_next_response("Reply with the JSON { response: 'yes' }", ["Give me the reply."], response_format: { type: "json_object" } ) - end - assert_equal({"response"=>"yes"}, response) - end - end -end diff --git a/test/support/test_client/open_ai.rb b/test/support/test_client/open_ai.rb index 3a0b4dd1b..6d8258c0b 100644 --- a/test/support/test_client/open_ai.rb +++ b/test/support/test_client/open_ai.rb @@ -1,27 +1,54 @@ module TestClient class OpenAI attr_reader :uri_base + def initialize(access_token:, uri_base:nil) @uri_base = uri_base end - def self.api_response - raise "When using the OpenAI test client you need to stub the .api_response method typically with either text_response or function_call_response" + def self.text + raise "Attempting to return a text response but .text method is not stubbed. Stub this to nil if you want to return default text." end - def self.text - raise "When using the OpenAI test client for api_text_response you need to stub the .text method" + def self.default_text + "Hello this is model #{@@model} with instruction #{@@instruction.inspect}! How can I assist you today?" + end + + def self.function + raise "Attempting to return a function response but .function method is not stubbed." + end + + def self.num_tool_calls + 1 end def self.parameters @@parameters end - def self.default_text - "Hello this is model #{@@model} with instruction #{@@instruction.inspect}! How can I assist you today?" + def self.api_oneoff_response + { + "id"=>"chatcmpl-A0ZcGrOn1iO5bUgDVFMEj7pX6ZB9A", + "object"=>"chat.completion", + "created"=>1724700272, + "model"=> @@model, + "choices"=>[ + { + "index"=>0, + "message"=>{ + "role"=>"assistant", + "content"=> text || default_text, + "refusal"=>nil + }, + "logprobs"=>nil, + "finish_reason"=>"stop" + } + ], + "usage"=>{"prompt_tokens"=>1243, "completion_tokens"=>11, "total_tokens"=>1254} + } end - def self.api_text_response + def self.api_streaming_response { "id"=> "chatcmpl-abc123abc123abc123abc123abc12", "object"=>"chat.completion", @@ -43,24 +70,12 @@ def self.api_text_response } end - def self.id - "call_BlAN9iRiAD6aCzmBWCjzYxjj" - end - - def self.function - raise "When using the OpenAI test client for api_function_response you need to stub the .function method" - end - - def self.arguments - {:city=>"Austin", :state=>"TX", :country=>"US"}.to_json - end - - def self.api_function_response(count = 1) + def self.api_function_response { "choices" => [ { "delta"=>{ - "tool_calls" => Array.new(count) { + "tool_calls" => Array.new(num_tool_calls) { { "index"=>0, "id"=>id, @@ -77,14 +92,29 @@ def self.api_function_response(count = 1) } end + def self.id + "call_BlAN9iRiAD6aCzmBWCjzYxjj" + end + + def self.arguments + {:city=>"Austin", :state=>"TX", :country=>"US"}.to_json + end + def chat(**args) @@model = args.dig(:parameters, :model) || "no model" @@instruction = args.dig(:parameters, :messages).first[:content] @@parameters = args.dig(:parameters) proc = args.dig(:parameters, :stream) - raise "No stream proc provided. When calling get_next_chat_message in tests be sure to include a block" if proc.nil? - proc.call(self.class.api_response) + tools = args.dig(:parameters, :tools) + + if proc && tools + proc.call(self.class.api_function_response) + elsif proc + proc.call(self.class.api_streaming_response) + else + self.class.api_oneoff_response + end end end end diff --git a/test/system/messages/nav_column_test.rb b/test/system/messages/nav_column_test.rb index 290ffdde3..9c95448cc 100644 --- a/test/system/messages/nav_column_test.rb +++ b/test/system/messages/nav_column_test.rb @@ -11,7 +11,7 @@ class NavColumnTest < ApplicationSystemTestCase page.execute_script("document.querySelector('#nav-scrollable').scrollTop = 100") # scroll the nav column down slightly assert_did_not_scroll "#nav-scrollable" do - click_text conversations(:hello_claude).title + click_text conversations(:attachment).title end end