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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
40
lib/integrations/llm_instrumentation_helpers.rb
Normal file
40
lib/integrations/llm_instrumentation_helpers.rb
Normal 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
|
||||
@@ -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
47
lib/llm/config.rb
Normal 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
|
||||
Reference in New Issue
Block a user