fix: log only on system api key failures (#13968)

Removes sentry flooding of unnecessary rubyllm logs of wrong API key.
Logs only system api key error since it would be P0.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aakash Bakhle 2026-04-09 18:04:52 +05:30 committed by GitHub
parent f1da7b8afa
commit f13f3ba446
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 109 additions and 20 deletions

View File

@ -27,9 +27,9 @@ class Captain::Llm::TranslateQueryService < Captain::BaseTaskService
end end
# Translation is an internal operation, not customer-initiated. # Translation is an internal operation, not customer-initiated.
# Prefer the system key; fall back to the account's hook key for self-hosted setups without one. # It should always use the installation key.
def api_key def llm_credential
@api_key ||= system_api_key.presence || openai_hook&.settings&.dig('api_key') @llm_credential ||= system_llm_credential
end end
def query_in_target_language?(query) def query_in_target_language?(query)

View File

@ -56,12 +56,10 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
{ complete: false, reason: reason } { complete: false, reason: reason }
end end
# Prefer the system API key over the account's OpenAI hook key.
# This is an internal operational evaluation, not a customer-triggered feature, # This is an internal operational evaluation, not a customer-triggered feature,
# so it should not consume the customer's OpenAI credits on hosted platforms. # so it should always use the installation key.
# Falls back to the account hook for self-hosted deployments without a system key. def llm_credential
def api_key @llm_credential ||= system_llm_credential
@api_key ||= system_api_key.presence || openai_hook&.settings&.dig('api_key')
end end
def event_name def event_name

View File

@ -1,6 +1,7 @@
class Captain::BaseTaskService class Captain::BaseTaskService
include Integrations::LlmInstrumentation include Integrations::LlmInstrumentation
include Captain::ToolInstrumentation include Captain::ToolInstrumentation
include Llm::ExceptionTrackable
# gpt-4o-mini supports 128,000 tokens # gpt-4o-mini supports 128,000 tokens
# 1 token is approx 4 characters # 1 token is approx 4 characters
@ -55,7 +56,9 @@ class Captain::BaseTaskService
end end
def execute_ruby_llm_request(model:, messages:, schema: nil, tools: []) def execute_ruby_llm_request(model:, messages:, schema: nil, tools: [])
Llm::Config.with_api_key(api_key, api_base: api_base) do |context| credential = llm_credential
Llm::Config.with_api_key(credential[:api_key], api_base: api_base) do |context|
chat = build_chat(context, model: model, messages: messages, schema: schema, tools: tools) chat = build_chat(context, model: model, messages: messages, schema: schema, tools: tools)
conversation_messages = messages.reject { |m| m[:role] == 'system' } conversation_messages = messages.reject { |m| m[:role] == 'system' }
@ -65,7 +68,7 @@ class Captain::BaseTaskService
build_ruby_llm_response(chat.ask(conversation_messages.last[:content]), messages) build_ruby_llm_response(chat.ask(conversation_messages.last[:content]), messages)
end end
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: account).capture_exception capture_llm_exception(e, credential: credential)
{ error: e.message, request_messages: messages } { error: e.message, request_messages: messages }
end end
@ -147,11 +150,24 @@ class Captain::BaseTaskService
end end
def api_key_configured? def api_key_configured?
api_key.present? llm_credential.present?
end end
def api_key def api_key
@api_key ||= openai_hook&.settings&.dig('api_key') || system_api_key llm_credential&.dig(:api_key)
end
def llm_credential
@llm_credential ||= hook_llm_credential || system_llm_credential
end
def hook_llm_credential
key = openai_hook&.settings&.dig('api_key').presence
{ api_key: key, source: :hook } if key
end
def system_llm_credential
{ api_key: system_api_key, source: :system } if system_api_key.present?
end end
def openai_hook def openai_hook
@ -162,6 +178,10 @@ class Captain::BaseTaskService
@system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value @system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
end end
def exception_tracking_account
account
end
def prompt_from_file(file_name) def prompt_from_file(file_name)
Rails.root.join('lib/integrations/openai/openai_prompts', "#{file_name}.liquid").read Rails.root.join('lib/integrations/openai/openai_prompts', "#{file_name}.liquid").read
end end

View File

@ -1,5 +1,6 @@
class Integrations::LlmBaseService class Integrations::LlmBaseService
include Integrations::LlmInstrumentation include Integrations::LlmInstrumentation
include Llm::ExceptionTrackable
# gpt-4o-mini supports 128,000 tokens # gpt-4o-mini supports 128,000 tokens
# 1 token is approx 4 characters # 1 token is approx 4 characters
@ -100,13 +101,14 @@ class Integrations::LlmBaseService
def execute_ruby_llm_request(parsed_body) def execute_ruby_llm_request(parsed_body)
messages = parsed_body['messages'] messages = parsed_body['messages']
model = parsed_body['model'] model = parsed_body['model']
credential = llm_credential
Llm::Config.with_api_key(hook.settings['api_key'], api_base: api_base) do |context| Llm::Config.with_api_key(credential[:api_key], api_base: api_base) do |context|
chat = context.chat(model: model) chat = context.chat(model: model)
setup_chat_with_messages(chat, messages) setup_chat_with_messages(chat, messages)
end end
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: hook.account).capture_exception capture_llm_exception(e, credential: credential)
build_error_response_from_exception(e, messages) build_error_response_from_exception(e, messages)
end end
@ -164,6 +166,14 @@ class Integrations::LlmBaseService
} }
end end
def llm_credential
@llm_credential ||= { api_key: hook.settings['api_key'], source: :hook }
end
def exception_tracking_account
hook.account
end
def build_error_response_from_exception(error, messages) def build_error_response_from_exception(error, messages)
{ error: error.message, request_messages: messages } { error: error.message, request_messages: messages }
end end

View File

@ -0,0 +1,11 @@
module Llm::ExceptionTrackable
private
def capture_llm_exception(error, credential:)
if credential && credential[:source] == :system
ChatwootExceptionTracker.new(error, account: exception_tracking_account).capture_exception
else
Rails.logger.error("[LLM] account=#{exception_tracking_account&.id} #{error.class}: #{error.message}")
end
end
end

View File

@ -141,15 +141,15 @@ RSpec.describe Captain::ConversationCompletionService do
service.perform service.perform
end end
it 'falls back to the account hook key when no system key exists' do it 'does not fall back to the account hook key when no system key exists' do
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY').update!(value: nil) InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY').update!(value: nil)
expect(Llm::Config).to receive(:with_api_key).with('customer-own-key', api_base: anything).and_yield(mock_context) expect(Llm::Config).not_to receive(:with_api_key)
allow(mock_chat).to receive(:ask).and_return(
instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5)
)
service.perform result = service.perform
expect(result[:complete]).to be false
expect(result[:reason]).to eq(I18n.t('captain.api_key_missing'))
end end
end end

View File

@ -258,6 +258,18 @@ RSpec.describe Captain::BaseTaskService do
expect(result[:error]).to eq('API Error') expect(result[:error]).to eq('API Error')
expect(result[:request_messages]).to eq(messages) expect(result[:request_messages]).to eq(messages)
end end
it 'does not track exceptions for account hook failures' do
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'hook-key' })
expect(Llm::Config).to receive(:with_api_key).with('hook-key', api_base: anything).and_raise(error)
expect(ChatwootExceptionTracker).not_to receive(:new)
result = service.send(:make_api_call, model: model, messages: messages)
expect(result[:error]).to eq('API Error')
expect(result[:request_messages]).to eq(messages)
end
end end
describe '#api_key' do describe '#api_key' do
@ -276,6 +288,16 @@ RSpec.describe Captain::BaseTaskService do
expect(service.send(:api_key)).to eq('test-key') expect(service.send(:api_key)).to eq('test-key')
end end
end end
context 'when no API key is configured' do
before do
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.destroy
end
it 'returns nil' do
expect(service.send(:api_key)).to be_nil
end
end
end end
describe '#prompt_from_file' do describe '#prompt_from_file' do

View File

@ -0,0 +1,28 @@
require 'rails_helper'
RSpec.describe Integrations::LlmBaseService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:hook) { create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'hook-key' }) }
let(:event) { { 'name' => 'summarize', 'data' => { 'conversation_display_id' => conversation.display_id } } }
let(:service) { described_class.new(hook: hook, event: event) }
let(:error) { StandardError.new('API Error') }
let(:body) { { model: 'gpt-4', messages: [{ role: 'user', content: 'Hello' }] }.to_json }
describe '#make_api_call' do
before do
allow(service).to receive(:instrument_llm_call).and_yield
allow(Llm::Config).to receive(:with_api_key).and_raise(error)
end
it 'does not track exceptions for hook key failures' do
expect(ChatwootExceptionTracker).not_to receive(:new)
result = service.send(:make_api_call, body)
expect(result[:error]).to eq('API Error')
expect(result[:request_messages]).to eq([{ 'role' => 'user', 'content' => 'Hello' }])
end
end
end