feat: wire up credit usage (#13260)

This commit is contained in:
Shivam Mishra
2026-01-13 18:09:34 +05:30
committed by GitHub
parent 82f5dbe6c1
commit 61425b6a3b
12 changed files with 261 additions and 19 deletions
+17 -18
View File
@@ -109,6 +109,21 @@ export function useAI() {
}
};
/**
* Handles API errors and displays appropriate error messages.
* Silently returns for aborted requests.
* @param {Error} error - The error object from the API call.
*/
const handleAPIError = error => {
if (error.name === 'AbortError' || error.name === 'CanceledError') {
return;
}
const errorMessage =
error.response?.data?.error ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
};
/**
* Records analytics for AI-related events.
* @param {string} type - The type of event.
@@ -171,15 +186,7 @@ export function useAI() {
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
// Don't show error for aborted requests
if (error.name === 'AbortError' || error.name === 'CanceledError') {
return { message: '' };
}
const errorData = error.response?.data?.error;
const errorMessage =
errorData?.error?.message ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
handleAPIError(error);
return { message: '' };
}
};
@@ -203,15 +210,7 @@ export function useAI() {
} = result;
return { message: generatedMessage, followUpContext: updatedContext };
} catch (error) {
// Don't show error for aborted requests
if (error.name === 'AbortError' || error.name === 'CanceledError') {
return { message: '', followUpContext };
}
const errorData = error.response?.data?.error;
const errorMessage =
errorData?.error?.message ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
handleAPIError(error);
return { message: '', followUpContext };
}
};
+2
View File
@@ -336,6 +336,8 @@ en:
copilot_message_required: Message is required
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
upgrade: 'Upgrade your plan to enable Captain AI'
disabled: 'Captain AI is disabled for this account.'
copilot:
using_tool: 'Using tool %{function_name}'
completed_tool_call: 'Completed %{function_name} tool call'
@@ -0,0 +1,34 @@
module Enterprise::Captain::BaseTaskService
def perform
return { error: I18n.t('captain.copilot_limit'), error_code: 429 } unless responses_available?
unless captain_enabled?
return { error: I18n.t('captain.upgrade') } if ChatwootApp.chatwoot_cloud?
return { error: I18n.t('captain.disabled') }
end
result = super
increment_usage if successful_result?(result)
result
end
private
def captain_enabled?
account.feature_enabled?('captain_integration')
end
def responses_available?
account.usage_limits[:captain][:responses][:current_available].positive?
end
def successful_result?(result)
result.is_a?(Hash) && result[:message].present? && !result[:error]
end
def increment_usage
Rails.logger.info("[CAPTAIN][#{self.class.name}] Incrementing response usage for account #{account.id}")
account.increment_response_usage
end
end
+11
View File
@@ -8,6 +8,15 @@ class Captain::BaseTaskService
TOKEN_LIMIT = 400_000
GPT_MODEL = Llm::Config::DEFAULT_MODEL
# Prepend enterprise module to subclasses when they're defined.
# This ensures the enterprise perform wrapper is applied even when
# subclasses define their own perform method, since prepend puts
# the module before the class in the ancestor chain.
def self.inherited(subclass)
super
subclass.prepend_mod_with('Captain::BaseTaskService')
end
pattr_initialize [:account!, { conversation_display_id: nil }]
private
@@ -147,3 +156,5 @@ class Captain::BaseTaskService
user_msg ? user_msg[:content] : nil
end
end
Captain::BaseTaskService.prepend_mod_with('Captain::BaseTaskService')
+2 -1
View File
@@ -1,7 +1,8 @@
require 'ruby_llm'
module Llm::Config
DEFAULT_MODEL = 'gpt-5-mini'.freeze
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
class << self
def initialized?
@initialized ||= false
@@ -0,0 +1,168 @@
require 'rails_helper'
RSpec.describe Captain::BaseTaskService, type: :model do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:perform_result) { { message: 'Test response' } }
# Create a concrete test service class with enterprise module prepended
let(:test_service_class) do
result = perform_result
klass = Class.new(described_class) do
define_method(:perform) { result }
def event_name
'test_event'
end
end
# Manually prepend enterprise module to test class
klass.prepend(Enterprise::Captain::BaseTaskService)
klass
end
let(:service) { test_service_class.new(account: account, conversation_display_id: conversation.display_id) }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
end
describe '#perform with enterprise usage tracking' do
# Ensure captain is enabled by default for tests unless explicitly testing disabled state
before do
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
end
context 'when usage limit is exceeded' do
before do
allow(account).to receive(:usage_limits).and_return({
captain: { responses: { current_available: 0 } }
})
end
it 'returns usage limit exceeded error' do
result = service.perform
expect(result[:error]).to eq(I18n.t('captain.copilot_limit'))
expect(result[:error_code]).to eq(429)
end
it 'does not increment usage' do
expect(account).not_to receive(:increment_response_usage)
service.perform
end
end
it 'increments response usage on successful execution' do
expect(account).to receive(:increment_response_usage)
service.perform
end
context 'when result has an error' do
let(:perform_result) { { error: 'API Error' } }
it 'does not increment usage' do
expect(account).not_to receive(:increment_response_usage)
service.perform
end
end
context 'when result is nil' do
let(:perform_result) { nil }
it 'does not increment usage' do
expect(account).not_to receive(:increment_response_usage)
service.perform
end
end
context 'when result is empty hash' do
let(:perform_result) { {} }
it 'does not increment usage' do
expect(account).not_to receive(:increment_response_usage)
service.perform
end
end
context 'when result has blank message' do
let(:perform_result) { { message: '' } }
it 'does not increment usage' do
expect(account).not_to receive(:increment_response_usage)
service.perform
end
end
context 'when result has nil message' do
let(:perform_result) { { message: nil } }
it 'does not increment usage' do
expect(account).not_to receive(:increment_response_usage)
service.perform
end
end
it 'actually increments the usage counter in custom_attributes' do
expect do
service.perform
account.reload
end.to change { account.custom_attributes['captain_responses_usage'].to_i }.by(1)
end
context 'when captain is disabled' do
before do
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(false)
end
context 'when on Chatwoot Cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
end
it 'returns upgrade error message' do
result = service.perform
expect(result[:error]).to eq(I18n.t('captain.upgrade'))
end
it 'does not increment usage' do
expect(account).not_to receive(:increment_response_usage)
service.perform
end
end
context 'when self-hosted' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
end
it 'returns disabled error message' do
result = service.perform
expect(result[:error]).to eq(I18n.t('captain.disabled'))
end
it 'does not increment usage' do
expect(account).not_to receive(:increment_response_usage)
service.perform
end
end
end
context 'when captain is enabled' do
before do
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
end
it 'proceeds with the task' do
result = service.perform
expect(result[:message]).to eq('Test response')
expect(result[:error]).to be_nil
end
it 'increments usage' do
expect(account).to receive(:increment_response_usage)
service.perform
end
end
end
end
@@ -22,6 +22,10 @@ RSpec.describe Captain::BaseTaskService do
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
# Stub captain enabled check to allow OSS specs to test base functionality
# without enterprise module interference
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
end
describe '#perform' do
@@ -25,6 +25,13 @@ RSpec.describe Captain::FollowUpService do
)
end
before do
# Stub captain enabled check to allow specs to test base functionality
# without enterprise module interference
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
end
describe '#perform' do
context 'when conversation_display_id is provided' do
it 'resolves conversation for instrumentation' do
@@ -18,6 +18,10 @@ RSpec.describe Captain::LabelSuggestionService do
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
allow(mock_chat).to receive(:with_instructions)
allow(mock_chat).to receive(:ask).and_return(mock_response)
# Stub captain enabled check to allow specs to test base functionality
# without enterprise module interference
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
end
describe '#label_suggestion_message' do
@@ -15,6 +15,10 @@ RSpec.describe Captain::ReplySuggestionService do
allow(mock_chat).to receive(:with_instructions)
allow(mock_chat).to receive(:add_message)
allow(mock_chat).to receive(:ask).and_return(mock_response)
# Stub captain enabled check to allow specs to test base functionality
# without enterprise module interference
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
end
describe '#perform' do
+4
View File
@@ -16,6 +16,10 @@ RSpec.describe Captain::RewriteService do
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
allow(mock_chat).to receive(:with_instructions)
allow(mock_chat).to receive(:ask).and_return(mock_response)
# Stub captain enabled check to allow specs to test base functionality
# without enterprise module interference
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
end
describe '#perform with fix_spelling_grammar operation' do
+4
View File
@@ -14,6 +14,10 @@ RSpec.describe Captain::SummaryService do
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
allow(mock_chat).to receive(:with_instructions)
allow(mock_chat).to receive(:ask).and_return(mock_response)
# Stub captain enabled check to allow specs to test base functionality
# without enterprise module interference
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
end
describe '#perform' do