feat: migrate editor to ruby-llm (#12961)

Co-authored-by: aakashb95 <aakash@chatwoot.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Aakash Bakhle
2025-12-04 12:51:35 +05:30
committed by GitHub
parent 5c3b85334b
commit 87fe1e9ad7
9 changed files with 333 additions and 235 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

47
lib/llm/config.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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