diff --git a/Gemfile b/Gemfile index faf7c2dea..f037583e7 100644 --- a/Gemfile +++ b/Gemfile @@ -194,6 +194,7 @@ gem 'ruby-openai' gem 'ai-agents', '>= 0.4.3' # TODO: Move this gem as a dependency of ai-agents +gem 'ruby_llm', '>= 1.9.1' gem 'ruby_llm-schema' # OpenTelemetry for LLM observability diff --git a/Gemfile.lock b/Gemfile.lock index e356c13a8..c3eec459b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -819,7 +819,7 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_llm (1.5.1) + ruby_llm (1.9.1) base64 event_stream_parser (~> 1) faraday (>= 1.10.0) @@ -827,8 +827,9 @@ GEM faraday-net_http (>= 1) faraday-retry (>= 1) marcel (~> 1.0) + ruby_llm-schema (~> 0.2.1) zeitwerk (~> 2) - ruby_llm-schema (0.1.0) + ruby_llm-schema (0.2.5) ruby_parser (3.20.0) sexp_processor (~> 4.16) sass (3.7.4) @@ -1119,6 +1120,7 @@ DEPENDENCIES rubocop-rails rubocop-rspec ruby-openai + ruby_llm (>= 1.9.1) ruby_llm-schema scout_apm scss_lint diff --git a/lib/integrations/openai_base_service.rb b/lib/integrations/llm_base_service.rb similarity index 61% rename from lib/integrations/openai_base_service.rb rename to lib/integrations/llm_base_service.rb index c19f605c1..e8811121f 100644 --- a/lib/integrations/openai_base_service.rb +++ b/lib/integrations/llm_base_service.rb @@ -1,4 +1,4 @@ -class Integrations::OpenaiBaseService +class Integrations::LlmBaseService include Integrations::LlmInstrumentation # gpt-4o-mini supports 128,000 tokens @@ -6,8 +6,7 @@ class Integrations::OpenaiBaseService # sticking with 120000 to be safe # 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe) TOKEN_LIMIT = 400_000 - GPT_MODEL = ENV.fetch('OPENAI_GPT_MODEL', 'gpt-4o-mini').freeze - + GPT_MODEL = Llm::Config::DEFAULT_MODEL ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze CACHEABLE_EVENTS = %w[].freeze @@ -82,10 +81,10 @@ class Integrations::OpenaiBaseService self.class::CACHEABLE_EVENTS.include?(event_name) end - def api_url + def api_base endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/' endpoint = endpoint.chomp('/') - "#{endpoint}/v1/chat/completions" + "#{endpoint}/v1" end def make_api_call(body) @@ -93,10 +92,65 @@ class Integrations::OpenaiBaseService instrumentation_params = build_instrumentation_params(parsed_body) instrument_llm_call(instrumentation_params) do - execute_api_request(body, parsed_body['messages']) + execute_ruby_llm_request(parsed_body) end end + def execute_ruby_llm_request(parsed_body) + messages = parsed_body['messages'] + model = parsed_body['model'] + + Llm::Config.with_api_key(hook.settings['api_key'], api_base: api_base) do |context| + chat = context.chat(model: model) + setup_chat_with_messages(chat, messages) + end + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: hook.account).capture_exception + build_error_response_from_exception(e, messages) + end + + def setup_chat_with_messages(chat, messages) + apply_system_instructions(chat, messages) + response = send_conversation_messages(chat, messages) + return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if response.nil? + + build_ruby_llm_response(response, messages) + end + + def apply_system_instructions(chat, messages) + system_msg = messages.find { |m| m['role'] == 'system' } + chat.with_instructions(system_msg['content']) if system_msg + end + + def send_conversation_messages(chat, messages) + conversation_messages = messages.reject { |m| m['role'] == 'system' } + + return nil if conversation_messages.empty? + + return chat.ask(conversation_messages.first['content']) if conversation_messages.length == 1 + + add_conversation_history(chat, conversation_messages[0...-1]) + chat.ask(conversation_messages.last['content']) + end + + def add_conversation_history(chat, messages) + messages.each do |msg| + chat.add_message(role: msg['role'].to_sym, content: msg['content']) + end + end + + def build_ruby_llm_response(response, messages) + { + message: response.content, + usage: { + 'prompt_tokens' => response.input_tokens, + 'completion_tokens' => response.output_tokens, + 'total_tokens' => (response.input_tokens || 0) + (response.output_tokens || 0) + }, + request_messages: messages + } + end + def build_instrumentation_params(parsed_body) { span_name: "llm.#{event_name}", @@ -109,37 +163,7 @@ class Integrations::OpenaiBaseService } end - def execute_api_request(body, messages) - Rails.logger.info("OpenAI API request: #{body}") - response = HTTParty.post(api_url, headers: api_headers, body: body) - Rails.logger.info("OpenAI API response: #{response.body}") - - parse_api_response(response, messages) - end - - def api_headers - { - 'Content-Type' => 'application/json', - 'Authorization' => "Bearer #{hook.settings['api_key']}" - } - end - - def parse_api_response(response, messages) - return build_error_response(response, messages) unless response.success? - - parsed_response = JSON.parse(response.body) - build_success_response(parsed_response, messages) - end - - def build_error_response(response, messages) - { error: response.parsed_response, error_code: response.code, request_messages: messages } - end - - def build_success_response(parsed_response, messages) - choices = parsed_response['choices'] - usage = parsed_response['usage'] - message_content = choices.present? ? choices.first['message']['content'] : nil - - { message: message_content, usage: usage, request_messages: messages } + def build_error_response_from_exception(error, messages) + { error: error.message, error_code: 500, request_messages: messages } end end diff --git a/lib/integrations/llm_instrumentation.rb b/lib/integrations/llm_instrumentation.rb index 919713dd3..7c176b758 100644 --- a/lib/integrations/llm_instrumentation.rb +++ b/lib/integrations/llm_instrumentation.rb @@ -2,9 +2,19 @@ require 'opentelemetry_config' require_relative 'llm_instrumentation_constants' +require_relative 'llm_instrumentation_helpers' module Integrations::LlmInstrumentation include Integrations::LlmInstrumentationConstants + include Integrations::LlmInstrumentationHelpers + + PROVIDER_PREFIXES = { + 'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-], + 'anthropic' => %w[claude-], + 'google' => %w[gemini-], + 'mistral' => %w[mistral- codestral-], + 'deepseek' => %w[deepseek-] + }.freeze def tracer @tracer ||= OpentelemetryConfig.tracer @@ -13,33 +23,38 @@ module Integrations::LlmInstrumentation def instrument_llm_call(params) return yield unless ChatwootApp.otel_enabled? + result = nil + executed = false tracer.in_span(params[:span_name]) do |span| setup_span_attributes(span, params) result = yield + executed = true record_completion(span, result) result end rescue StandardError => e - ChatwootExceptionTracker.new(e, account: params[:account]).capture_exception - yield + ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception + executed ? result : yield end def instrument_agent_session(params) return yield unless ChatwootApp.otel_enabled? + result = nil + executed = false tracer.in_span(params[:span_name]) do |span| set_metadata_attributes(span, params) # By default, the input and output of a trace are set from the root observation span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json) result = yield + executed = true span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json) - result end rescue StandardError => e - ChatwootExceptionTracker.new(e, account: params[:account]).capture_exception - yield + ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception + executed ? result : yield end def instrument_tool_call(tool_name, arguments) @@ -55,8 +70,27 @@ module Integrations::LlmInstrumentation end end + def determine_provider(model_name) + return 'openai' if model_name.blank? + + model = model_name.to_s.downcase + + PROVIDER_PREFIXES.each do |provider, prefixes| + return provider if prefixes.any? { |prefix| model.start_with?(prefix) } + end + + 'openai' + end + private + def resolve_account(params) + return params[:account] if params[:account].is_a?(Account) + return Account.find_by(id: params[:account_id]) if params[:account_id].present? + + nil + end + def setup_span_attributes(span, params) set_request_attributes(span, params) set_prompt_messages(span, params[:messages]) @@ -68,7 +102,8 @@ module Integrations::LlmInstrumentation end def set_request_attributes(span, params) - span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai') + provider = determine_provider(params[:model]) + span.set_attribute(ATTR_GEN_AI_PROVIDER, provider) span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model]) span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature] end @@ -95,37 +130,4 @@ module Integrations::LlmInstrumentation span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s) end end - - def set_completion_attributes(span, result) - set_completion_message(span, result) - set_usage_metrics(span, result) - set_error_attributes(span, result) - end - - def set_completion_message(span, result) - message = result[:message] || result.dig('choices', 0, 'message', 'content') - return if message.blank? - - span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant') - span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message) - end - - def set_usage_metrics(span, result) - usage = result[:usage] || result['usage'] - return if usage.blank? - - span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens'] - span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens'] - span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens'] - end - - def set_error_attributes(span, result) - error = result[:error] || result['error'] - return if error.blank? - - error_code = result[:error_code] || result['error_code'] - span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json) - span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, error_code) if error_code - span.status = OpenTelemetry::Trace::Status.error("API Error: #{error_code}") - end end diff --git a/lib/integrations/llm_instrumentation_helpers.rb b/lib/integrations/llm_instrumentation_helpers.rb new file mode 100644 index 000000000..d1bf5c25c --- /dev/null +++ b/lib/integrations/llm_instrumentation_helpers.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Integrations::LlmInstrumentationHelpers + include Integrations::LlmInstrumentationConstants + + private + + def set_completion_attributes(span, result) + set_completion_message(span, result) + set_usage_metrics(span, result) + set_error_attributes(span, result) + end + + def set_completion_message(span, result) + message = result[:message] || result.dig('choices', 0, 'message', 'content') + return if message.blank? + + span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant') + span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message) + end + + def set_usage_metrics(span, result) + usage = result[:usage] || result['usage'] + return if usage.blank? + + span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens'] + span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens'] + span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens'] + end + + def set_error_attributes(span, result) + error = result[:error] || result['error'] + return if error.blank? + + error_code = result[:error_code] || result['error_code'] + span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json) + span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, error_code) if error_code + span.status = OpenTelemetry::Trace::Status.error("API Error: #{error_code}") + end +end diff --git a/lib/integrations/openai/processor_service.rb b/lib/integrations/openai/processor_service.rb index 1359c6aff..0a0dfa8ae 100644 --- a/lib/integrations/openai/processor_service.rb +++ b/lib/integrations/openai/processor_service.rb @@ -1,4 +1,4 @@ -class Integrations::Openai::ProcessorService < Integrations::OpenaiBaseService +class Integrations::Openai::ProcessorService < Integrations::LlmBaseService AGENT_INSTRUCTION = 'You are a helpful support agent.'.freeze LANGUAGE_INSTRUCTION = 'Ensure that the reply should be in user language.'.freeze def reply_suggestion_message diff --git a/lib/llm/config.rb b/lib/llm/config.rb new file mode 100644 index 000000000..b2edc81e7 --- /dev/null +++ b/lib/llm/config.rb @@ -0,0 +1,47 @@ +require 'ruby_llm' + +module Llm::Config + DEFAULT_MODEL = 'gpt-4o-mini'.freeze + class << self + def initialized? + @initialized ||= false + end + + def initialize! + return if @initialized + + configure_ruby_llm + @initialized = true + end + + def reset! + @initialized = false + end + + def with_api_key(api_key, api_base: nil) + context = RubyLLM.context do |config| + config.openai_api_key = api_key + config.openai_api_base = api_base + end + + yield context + end + + private + + def configure_ruby_llm + RubyLLM.configure do |config| + config.openai_api_key = system_api_key if system_api_key.present? + config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present? + end + end + + def system_api_key + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value + end + + def openai_endpoint + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value + end + end +end diff --git a/spec/enterprise/lib/integrations/openai/processor_service_spec.rb b/spec/enterprise/lib/integrations/openai/processor_service_spec.rb index 4707aff87..88e75ea07 100644 --- a/spec/enterprise/lib/integrations/openai/processor_service_spec.rb +++ b/spec/enterprise/lib/integrations/openai/processor_service_spec.rb @@ -5,21 +5,39 @@ RSpec.describe Integrations::Openai::ProcessorService do let(:account) { create(:account) } let(:hook) { create(:integrations_hook, :openai, account: account) } - let(:expected_headers) { { 'Authorization' => "Bearer #{hook.settings['api_key']}" } } - let(:openai_response) do - { - 'choices' => [ - { - 'message' => { - 'content' => 'This is a reply from openai.' - } - } - ] - }.to_json + + # Mock RubyLLM objects + let(:mock_chat) { instance_double(RubyLLM::Chat) } + let(:mock_context) { instance_double(RubyLLM::Context) } + let(:mock_config) { OpenStruct.new } + let(:mock_response) do + instance_double( + RubyLLM::Message, + content: 'This is a reply from openai.', + input_tokens: nil, + output_tokens: nil + ) + end + let(:mock_empty_response) do + instance_double( + RubyLLM::Message, + content: '', + input_tokens: nil, + output_tokens: nil + ) end let(:conversation) { create(:conversation, account: account) } + before do + allow(RubyLLM).to receive(:context).and_yield(mock_config).and_return(mock_context) + allow(mock_context).to receive(:chat).and_return(mock_chat) + + allow(mock_chat).to receive(:with_instructions).and_return(mock_chat) + allow(mock_chat).to receive(:add_message).and_return(mock_chat) + allow(mock_chat).to receive(:ask).and_return(mock_response) + end + describe '#perform' do context 'when event name is label_suggestion with labels with < 3 messages' do let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } } @@ -49,21 +67,15 @@ RSpec.describe Integrations::Openai::ProcessorService do end it 'returns the label suggestions' do - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: anything, headers: expected_headers) - .to_return(status: 200, body: openai_response, headers: {}) - result = subject.perform - expect(result).to eq({ :message => 'This is a reply from openai.' }) + expect(result).to eq({ message: 'This is a reply from openai.' }) end it 'returns empty string if openai response is blank' do - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: anything, headers: expected_headers) - .to_return(status: 200, body: '{}', headers: {}) + allow(mock_chat).to receive(:ask).and_return(mock_empty_response) result = subject.perform - expect(result).to eq({ :message => '' }) + expect(result[:message]).to eq('') end end diff --git a/spec/lib/integrations/openai/processor_service_spec.rb b/spec/lib/integrations/openai/processor_service_spec.rb index 6f8e6ecf6..28488cc15 100644 --- a/spec/lib/integrations/openai/processor_service_spec.rb +++ b/spec/lib/integrations/openai/processor_service_spec.rb @@ -5,135 +5,104 @@ RSpec.describe Integrations::Openai::ProcessorService do let(:account) { create(:account) } let(:hook) { create(:integrations_hook, :openai, account: account) } - let(:expected_headers) { { 'Authorization' => "Bearer #{hook.settings['api_key']}" } } - let(:openai_response) do - { - 'choices' => [{ 'message' => { 'content' => 'This is a reply from openai.' } }] - }.to_json + + # Mock RubyLLM objects + let(:mock_chat) { instance_double(RubyLLM::Chat) } + let(:mock_context) { instance_double(RubyLLM::Context) } + let(:mock_config) { OpenStruct.new } + let(:mock_response) do + instance_double( + RubyLLM::Message, + content: 'This is a reply from openai.', + input_tokens: nil, + output_tokens: nil + ) end - let(:openai_response_with_usage) do - { - 'choices' => [{ 'message' => { 'content' => 'This is a reply from openai.' } }], - 'usage' => { - 'prompt_tokens' => 50, - 'completion_tokens' => 20, - 'total_tokens' => 70 - } - }.to_json + let(:mock_response_with_usage) do + instance_double( + RubyLLM::Message, + content: 'This is a reply from openai.', + input_tokens: 50, + output_tokens: 20 + ) + end + + before do + allow(RubyLLM).to receive(:context).and_yield(mock_config).and_return(mock_context) + allow(mock_context).to receive(:chat).and_return(mock_chat) + + allow(mock_chat).to receive(:with_instructions).and_return(mock_chat) + allow(mock_chat).to receive(:add_message).and_return(mock_chat) + allow(mock_chat).to receive(:ask).and_return(mock_response) end describe '#perform' do - shared_examples 'text transformation operation' do |event_name, system_prompt| - let(:event) { { 'name' => event_name, 'data' => { 'content' => 'This is a test' } } } - let(:expected_request_body) do - { - 'model' => 'gpt-4o-mini', - 'messages' => [ - { 'role' => 'system', 'content' => system_prompt }, - { 'role' => 'user', 'content' => 'This is a test' } - ] - }.to_json - end - - it "returns the #{event_name.tr('_', ' ')} text" do - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: expected_request_body, headers: expected_headers) - .to_return(status: 200, body: openai_response) - - result = service.perform - expect(result[:message]).to eq('This is a reply from openai.') - end - end - - shared_examples 'successful openai response' do - it 'returns the expected message' do - result = service.perform - expect(result[:message]).to eq('This is a reply from openai.') - end - end - describe 'text transformation operations' do - base_prompt = 'You are a helpful support agent. ' - language_suffix = 'Ensure that the reply should be in user language.' + shared_examples 'text transformation operation' do |event_name| + let(:event) { { 'name' => event_name, 'data' => { 'content' => 'This is a test' } } } - it_behaves_like 'text transformation operation', 'rephrase', - "#{base_prompt}Please rephrase the following response. #{language_suffix}" - it_behaves_like 'text transformation operation', 'fix_spelling_grammar', - "#{base_prompt}Please fix the spelling and grammar of the following response. #{language_suffix}" - it_behaves_like 'text transformation operation', 'shorten', - "#{base_prompt}Please shorten the following response. #{language_suffix}" - it_behaves_like 'text transformation operation', 'expand', - "#{base_prompt}Please expand the following response. #{language_suffix}" - it_behaves_like 'text transformation operation', 'make_friendly', - "#{base_prompt}Please make the following response more friendly. #{language_suffix}" - it_behaves_like 'text transformation operation', 'make_formal', - "#{base_prompt}Please make the following response more formal. #{language_suffix}" - it_behaves_like 'text transformation operation', 'simplify', - "#{base_prompt}Please simplify the following response. #{language_suffix}" + it 'returns the transformed text' do + result = service.perform + expect(result[:message]).to eq('This is a reply from openai.') + end + + it 'sends the user content to the LLM' do + service.perform + expect(mock_chat).to have_received(:ask).with('This is a test') + end + + it 'sets system instructions' do + service.perform + expect(mock_chat).to have_received(:with_instructions).with(a_string_including('You are a helpful support agent')) + end + end + + it_behaves_like 'text transformation operation', 'rephrase' + it_behaves_like 'text transformation operation', 'fix_spelling_grammar' + it_behaves_like 'text transformation operation', 'shorten' + it_behaves_like 'text transformation operation', 'expand' + it_behaves_like 'text transformation operation', 'make_friendly' + it_behaves_like 'text transformation operation', 'make_formal' + it_behaves_like 'text transformation operation', 'simplify' end describe 'conversation-based operations' do let!(:conversation) { create(:conversation, account: account) } - let!(:customer_message) do + + before do create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent') - end - let!(:agent_message) do create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer') end context 'with reply_suggestion event' do let(:event) { { 'name' => 'reply_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } } - let(:expected_request_body) do - { - 'model' => 'gpt-4o-mini', - 'messages' => [ - { role: 'system', content: Rails.root.join('lib/integrations/openai/openai_prompts/reply.txt').read }, - { role: 'user', content: customer_message.content }, - { role: 'assistant', content: agent_message.content } - ] - }.to_json + + it 'returns the suggested reply' do + result = service.perform + expect(result[:message]).to eq('This is a reply from openai.') end - before do - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: expected_request_body, headers: expected_headers) - .to_return(status: 200, body: openai_response) + it 'adds conversation history before asking' do + service.perform + # Should add the first message as history, then ask with the last message + expect(mock_chat).to have_received(:add_message).with(role: :user, content: 'hello agent') + expect(mock_chat).to have_received(:ask).with('hello customer') end - - it_behaves_like 'successful openai response' end context 'with summarize event' do let(:event) { { 'name' => 'summarize', 'data' => { 'conversation_display_id' => conversation.display_id } } } - let(:conversation_messages) do - "Customer #{customer_message.sender.name} : #{customer_message.content}\n" \ - "Agent #{agent_message.sender.name} : #{agent_message.content}\n" - end - let(:summary_prompt) do - if ChatwootApp.enterprise? - Rails.root.join('enterprise/lib/enterprise/integrations/openai_prompts/summary.txt').read - else - 'Please summarize the key points from the following conversation between support agents and customer as bullet points ' \ - "for the next support agent looking into the conversation. Reply in the user's language." - end - end - let(:expected_request_body) do - { - 'model' => 'gpt-4o-mini', - 'messages' => [ - { 'role' => 'system', 'content' => summary_prompt }, - { 'role' => 'user', 'content' => conversation_messages } - ] - }.to_json + + it 'returns the summary' do + result = service.perform + expect(result[:message]).to eq('This is a reply from openai.') end - before do - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: expected_request_body, headers: expected_headers) - .to_return(status: 200, body: openai_response) + it 'sends formatted conversation as a single message' do + service.perform + # Summarize sends conversation as a formatted string in one user message + expect(mock_chat).to have_received(:ask).with(a_string_matching(/Customer.*hello agent.*Agent.*hello customer/m)) end - - it_behaves_like 'successful openai response' end context 'with label_suggestion event and no labels' do @@ -160,37 +129,37 @@ RSpec.describe Integrations::Openai::ProcessorService do context 'when response includes usage data' do before do - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: anything, headers: expected_headers) - .to_return(status: 200, body: openai_response_with_usage) + allow(mock_chat).to receive(:ask).and_return(mock_response_with_usage) end - it 'returns message, usage, and request_messages' do + it 'returns message with usage data' do result = service.perform expect(result[:message]).to eq('This is a reply from openai.') - expect(result[:usage]).to eq({ - 'prompt_tokens' => 50, - 'completion_tokens' => 20, - 'total_tokens' => 70 - }) + expect(result[:usage]['prompt_tokens']).to eq(50) + expect(result[:usage]['completion_tokens']).to eq(20) + expect(result[:usage]['total_tokens']).to eq(70) + end + + it 'includes request_messages in response' do + result = service.perform + expect(result[:request_messages]).to be_an(Array) expect(result[:request_messages].length).to eq(2) end end context 'when response does not include usage data' do - before do - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .with(body: anything, headers: expected_headers) - .to_return(status: 200, body: openai_response) - end - - it 'returns message and request_messages with nil usage' do + it 'returns message with zero total tokens' do result = service.perform expect(result[:message]).to eq('This is a reply from openai.') - expect(result[:usage]).to be_nil + expect(result[:usage]['total_tokens']).to eq(0) + end + + it 'includes request_messages in response' do + result = service.perform + expect(result[:request_messages]).to be_an(Array) end end @@ -199,23 +168,17 @@ RSpec.describe Integrations::Openai::ProcessorService do describe 'endpoint configuration' do let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } } - shared_examples 'endpoint request' do |endpoint_url| - it "makes request to #{endpoint_url}" do - stub_request(:post, "#{endpoint_url}/v1/chat/completions") - .with(body: anything, headers: expected_headers) - .to_return(status: 200, body: openai_response) - - result = service.perform - expect(result[:message]).to eq('This is a reply from openai.') - expect(result[:request_messages]).to be_an(Array) - expect(result[:usage]).to be_nil - end - end - context 'without CAPTAIN_OPEN_AI_ENDPOINT configured' do before { InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy } - it_behaves_like 'endpoint request', 'https://api.openai.com' + it 'uses default OpenAI endpoint' do + expect(Llm::Config).to receive(:with_api_key).with( + hook.settings['api_key'], + api_base: 'https://api.openai.com/v1' + ).and_call_original + + service.perform + end end context 'with CAPTAIN_OPEN_AI_ENDPOINT configured' do @@ -224,7 +187,14 @@ RSpec.describe Integrations::Openai::ProcessorService do create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/') end - it_behaves_like 'endpoint request', 'https://custom.azure.com' + it 'uses custom endpoint' do + expect(Llm::Config).to receive(:with_api_key).with( + hook.settings['api_key'], + api_base: 'https://custom.azure.com/v1' + ).and_call_original + + service.perform + end end end end