diff --git a/enterprise/lib/enterprise/captain/base_task_service.rb b/enterprise/lib/enterprise/captain/base_task_service.rb index b080ff807..bda4708af 100644 --- a/enterprise/lib/enterprise/captain/base_task_service.rb +++ b/enterprise/lib/enterprise/captain/base_task_service.rb @@ -28,7 +28,7 @@ module Enterprise::Captain::BaseTaskService end def increment_usage - credits = Llm::Models.credit_multiplier_for(configured_model) + credits = using_hook_key? ? 1 : Llm::Models.credit_multiplier_for(configured_model) Rails.logger.info("[CAPTAIN][#{self.class.name}] Incrementing response usage for account #{account.id} by #{credits} credits") account.increment_response_usage(credits: credits) end diff --git a/lib/captain/base_task_service.rb b/lib/captain/base_task_service.rb index 739239cd2..e14048451 100644 --- a/lib/captain/base_task_service.rb +++ b/lib/captain/base_task_service.rb @@ -72,6 +72,11 @@ class Captain::BaseTaskService build_ruby_llm_response(response, messages) end rescue StandardError => e + # TODO: RubyLLM throws the RubyLLM::Error when any API based error occurs + # Just like inboxes have a reauthroizable error handling mechanism, + # we should handle the errors if the account is using their own key. + # If more than 5 errors occur, we disable the hook and notify the admins + # see: https://rubyllm.com/error-handling/#rubyllm-error-hierarchy ChatwootExceptionTracker.new(e, account: account).capture_exception { error: e.message, request_messages: messages } end @@ -128,6 +133,8 @@ class Captain::BaseTaskService end def api_key + # TODO: Once we support multiple LLM providers, ensure provider key is correctly looked up + # Currently works because the only model/provider is OpenAI @api_key ||= openai_hook&.settings&.dig('api_key') || system_api_key end @@ -135,6 +142,12 @@ class Captain::BaseTaskService @openai_hook ||= account.hooks.find_by(app_id: 'openai', status: 'enabled') end + def using_hook_key? + # TODO: Once we support multiple LLM providers, ensure provider key is correctly looked up + # Currently works because the only model/provider is OpenAI + openai_hook&.settings&.dig('api_key').present? + end + def system_api_key @system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value end diff --git a/spec/enterprise/lib/captain/base_task_service_spec.rb b/spec/enterprise/lib/captain/base_task_service_spec.rb index e0dd5fbab..cb9679bb5 100644 --- a/spec/enterprise/lib/captain/base_task_service_spec.rb +++ b/spec/enterprise/lib/captain/base_task_service_spec.rb @@ -69,6 +69,22 @@ RSpec.describe Captain::BaseTaskService, type: :model do service.perform end + context 'when account uses their own OpenAI key via hook' do + let!(:openai_hook) { create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'user-own-key' }) } + + it 'uses 1 credit regardless of model cost' do + allow(account).to receive(:captain_editor_model).and_return('gpt-5.1') + expect(account).to receive(:increment_response_usage).with(credits: 1) + service.perform + end + + it 'uses 1 credit even for expensive models' do + allow(account).to receive(:captain_editor_model).and_return('gpt-4.1') + expect(account).to receive(:increment_response_usage).with(credits: 1) + service.perform + end + end + context 'when result has an error' do let(:perform_result) { { error: 'API Error' } }