From bd14e96ed90208b63c55588c88fec4001a24ead6 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:40:37 +0530 Subject: [PATCH 1/5] chore: allow article to create without content (#14007) --- .../public/api/v1/portals/articles_controller.rb | 2 +- .../components/widgets/WootWriter/FullEditor.vue | 4 ++-- .../helpcenter/pages/PortalsArticlesNewPage.vue | 2 +- app/models/article.rb | 2 +- spec/models/article_spec.rb | 11 ++++++++++- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index a8e22d878..2bbfafcc7 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -62,7 +62,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def set_article @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) - @parsed_content = render_article_content(@article.content) + @parsed_content = render_article_content(@article.content.to_s) end def set_category diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue index 817f6e2b9..976ed3270 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue @@ -79,7 +79,7 @@ export default { created() { state = createState( - this.modelValue, + this.modelValue || '', this.placeholder, this.plugins, { onImageUpload: this.openFileBrowser }, @@ -170,7 +170,7 @@ export default { }, reloadState() { state = createState( - this.modelValue, + this.modelValue || '', this.placeholder, this.plugins, { onImageUpload: this.openFileBrowser }, diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesNewPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesNewPage.vue index 3833573ad..0c541fd19 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesNewPage.vue +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesNewPage.vue @@ -39,7 +39,7 @@ const createNewArticle = async ({ title, content }) => { if (title) article.value.title = title; if (content) article.value.content = content; - if (!article.value.title || !article.value.content) return; + if (!article.value.title) return; isUpdating.value = true; try { diff --git a/app/models/article.rb b/app/models/article.rb index cb6215157..2a56b006d 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -58,7 +58,7 @@ class Article < ApplicationRecord validates :account_id, presence: true validates :author_id, presence: true validates :title, presence: true - validates :content, presence: true + validates :content, presence: true, if: :published? # ensuring that the position is always set correctly before_create :add_position_to_article diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb index 161f3541d..5741b95f0 100644 --- a/spec/models/article_spec.rb +++ b/spec/models/article_spec.rb @@ -10,7 +10,16 @@ RSpec.describe Article do it { is_expected.to validate_presence_of(:account_id) } it { is_expected.to validate_presence_of(:author_id) } it { is_expected.to validate_presence_of(:title) } - it { is_expected.to validate_presence_of(:content) } + + it 'validates content presence only for published articles' do + article = build(:article, portal_id: portal_1.id, author_id: user.id, category_id: category_1.id, + title: 'test', content: nil, status: :draft) + expect(article).to be_valid + + article.status = :published + expect(article).not_to be_valid + expect(article.errors[:content]).to include("can't be blank") + end end describe 'associations' do From f1da7b8afa552dbb579d678a21afa8ad23797188 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:14:17 +0530 Subject: [PATCH 2/5] feat: enable assignment v2 by default for new accounts (#14031) ## Description Enable assignment v2 by default for new accounts ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- config/features.yml | 2 +- ...091202_enable_assignment_v2_for_new_accounts.rb | 14 ++++++++++++++ db/schema.rb | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20260409091202_enable_assignment_v2_for_new_accounts.rb diff --git a/config/features.yml b/config/features.yml index 00f9321b8..8a7074e71 100644 --- a/config/features.yml +++ b/config/features.yml @@ -190,7 +190,7 @@ chatwoot_internal: true - name: assignment_v2 display_name: Assignment V2 - enabled: false + enabled: true - name: twilio_content_templates display_name: Twilio Content Templates enabled: false diff --git a/db/migrate/20260409091202_enable_assignment_v2_for_new_accounts.rb b/db/migrate/20260409091202_enable_assignment_v2_for_new_accounts.rb new file mode 100644 index 000000000..c7c66bb51 --- /dev/null +++ b/db/migrate/20260409091202_enable_assignment_v2_for_new_accounts.rb @@ -0,0 +1,14 @@ +class EnableAssignmentV2ForNewAccounts < ActiveRecord::Migration[7.1] + def up + config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS') + return if config&.value.blank? + + features = config.value + feature = features.find { |f| f['name'] == 'assignment_v2' } + return if feature.blank? + + feature['enabled'] = true + config.update!(value: features) + GlobalConfig.clear_cache + end +end diff --git a/db/schema.rb b/db/schema.rb index d0993a55b..0067f36ff 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_03_24_102005) do +ActiveRecord::Schema[7.1].define(version: 2026_04_09_091202) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" From f13f3ba44680af551444d1304823fbcf83ae9270 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:04:52 +0530 Subject: [PATCH 3/5] 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) --- .../captain/llm/translate_query_service.rb | 6 ++-- .../conversation_completion_service.rb | 8 ++---- lib/captain/base_task_service.rb | 28 ++++++++++++++++--- lib/integrations/llm_base_service.rb | 14 ++++++++-- lib/llm/exception_trackable.rb | 11 ++++++++ .../conversation_completion_service_spec.rb | 12 ++++---- spec/lib/captain/base_task_service_spec.rb | 22 +++++++++++++++ .../lib/integrations/llm_base_service_spec.rb | 28 +++++++++++++++++++ 8 files changed, 109 insertions(+), 20 deletions(-) create mode 100644 lib/llm/exception_trackable.rb create mode 100644 spec/lib/integrations/llm_base_service_spec.rb diff --git a/enterprise/app/services/captain/llm/translate_query_service.rb b/enterprise/app/services/captain/llm/translate_query_service.rb index bdff88150..93f68b05b 100644 --- a/enterprise/app/services/captain/llm/translate_query_service.rb +++ b/enterprise/app/services/captain/llm/translate_query_service.rb @@ -27,9 +27,9 @@ class Captain::Llm::TranslateQueryService < Captain::BaseTaskService end # 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. - def api_key - @api_key ||= system_api_key.presence || openai_hook&.settings&.dig('api_key') + # It should always use the installation key. + def llm_credential + @llm_credential ||= system_llm_credential end def query_in_target_language?(query) diff --git a/enterprise/lib/captain/conversation_completion_service.rb b/enterprise/lib/captain/conversation_completion_service.rb index e9cdc8937..aa40e8000 100644 --- a/enterprise/lib/captain/conversation_completion_service.rb +++ b/enterprise/lib/captain/conversation_completion_service.rb @@ -56,12 +56,10 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService { complete: false, reason: reason } end - # Prefer the system API key over the account's OpenAI hook key. # This is an internal operational evaluation, not a customer-triggered feature, - # so it should not consume the customer's OpenAI credits on hosted platforms. - # Falls back to the account hook for self-hosted deployments without a system key. - def api_key - @api_key ||= system_api_key.presence || openai_hook&.settings&.dig('api_key') + # so it should always use the installation key. + def llm_credential + @llm_credential ||= system_llm_credential end def event_name diff --git a/lib/captain/base_task_service.rb b/lib/captain/base_task_service.rb index 60e6ac579..123377ea0 100644 --- a/lib/captain/base_task_service.rb +++ b/lib/captain/base_task_service.rb @@ -1,6 +1,7 @@ class Captain::BaseTaskService include Integrations::LlmInstrumentation include Captain::ToolInstrumentation + include Llm::ExceptionTrackable # gpt-4o-mini supports 128,000 tokens # 1 token is approx 4 characters @@ -55,7 +56,9 @@ class Captain::BaseTaskService end 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) 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) end rescue StandardError => e - ChatwootExceptionTracker.new(e, account: account).capture_exception + capture_llm_exception(e, credential: credential) { error: e.message, request_messages: messages } end @@ -147,11 +150,24 @@ class Captain::BaseTaskService end def api_key_configured? - api_key.present? + llm_credential.present? end 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 def openai_hook @@ -162,6 +178,10 @@ class Captain::BaseTaskService @system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value end + def exception_tracking_account + account + end + def prompt_from_file(file_name) Rails.root.join('lib/integrations/openai/openai_prompts', "#{file_name}.liquid").read end diff --git a/lib/integrations/llm_base_service.rb b/lib/integrations/llm_base_service.rb index 397888b83..8410130ee 100644 --- a/lib/integrations/llm_base_service.rb +++ b/lib/integrations/llm_base_service.rb @@ -1,5 +1,6 @@ class Integrations::LlmBaseService include Integrations::LlmInstrumentation + include Llm::ExceptionTrackable # gpt-4o-mini supports 128,000 tokens # 1 token is approx 4 characters @@ -100,13 +101,14 @@ class Integrations::LlmBaseService def execute_ruby_llm_request(parsed_body) messages = parsed_body['messages'] 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) setup_chat_with_messages(chat, messages) end rescue StandardError => e - ChatwootExceptionTracker.new(e, account: hook.account).capture_exception + capture_llm_exception(e, credential: credential) build_error_response_from_exception(e, messages) end @@ -164,6 +166,14 @@ class Integrations::LlmBaseService } 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) { error: error.message, request_messages: messages } end diff --git a/lib/llm/exception_trackable.rb b/lib/llm/exception_trackable.rb new file mode 100644 index 000000000..a2bb48618 --- /dev/null +++ b/lib/llm/exception_trackable.rb @@ -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 diff --git a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb index 58b2b2ce6..5c2000a84 100644 --- a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb +++ b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb @@ -141,15 +141,15 @@ RSpec.describe Captain::ConversationCompletionService do service.perform 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) - expect(Llm::Config).to receive(:with_api_key).with('customer-own-key', api_base: anything).and_yield(mock_context) - allow(mock_chat).to receive(:ask).and_return( - instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5) - ) + expect(Llm::Config).not_to receive(:with_api_key) - service.perform + result = service.perform + + expect(result[:complete]).to be false + expect(result[:reason]).to eq(I18n.t('captain.api_key_missing')) end end diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb index b3c330252..cb8a2ae2c 100644 --- a/spec/lib/captain/base_task_service_spec.rb +++ b/spec/lib/captain/base_task_service_spec.rb @@ -258,6 +258,18 @@ RSpec.describe Captain::BaseTaskService do expect(result[:error]).to eq('API Error') expect(result[:request_messages]).to eq(messages) 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 describe '#api_key' do @@ -276,6 +288,16 @@ RSpec.describe Captain::BaseTaskService do expect(service.send(:api_key)).to eq('test-key') 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 describe '#prompt_from_file' do diff --git a/spec/lib/integrations/llm_base_service_spec.rb b/spec/lib/integrations/llm_base_service_spec.rb new file mode 100644 index 000000000..fc23d18ba --- /dev/null +++ b/spec/lib/integrations/llm_base_service_spec.rb @@ -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 From 42163946ebace8c898912f3e4d8fe7abefa55116 Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 9 Apr 2026 23:12:44 -0700 Subject: [PATCH 4/5] fix: Ignore RoutingError in New Relic error reporting (#14030) Routing errors (404s) are expected in production and don't represent actionable issues. Reporting them to New Relic creates noise and makes it harder to spot real errors. Adds ActionController::RoutingError to the New Relic error_collector.ignore_errors list so these are no longer tracked as exceptions. --- config/newrelic.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/newrelic.yml b/config/newrelic.yml index e1482e4a1..921d6edfb 100644 --- a/config/newrelic.yml +++ b/config/newrelic.yml @@ -18,6 +18,9 @@ common: &default_settings distributed_tracing: enabled: true + error_collector: + ignore_errors: 'ActionController::RoutingError' + # To disable the agent regardless of other settings, uncomment the following: agent_enabled: <%= ENV['NEW_RELIC_LICENSE_KEY'].present? && ENV.fetch('NEW_RELIC_AGENT_ENABLED', true) %> From 3190b29fe9cd307afdb49eafe8a14d2b8fb48459 Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 9 Apr 2026 23:57:15 -0700 Subject: [PATCH 5/5] fix(revert): "fix: Ignore RoutingError in New Relic error reporting (#14030)" (#14038) This reverts commit 42163946ebace8c898912f3e4d8fe7abefa55116. --- config/newrelic.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/config/newrelic.yml b/config/newrelic.yml index 921d6edfb..e1482e4a1 100644 --- a/config/newrelic.yml +++ b/config/newrelic.yml @@ -18,9 +18,6 @@ common: &default_settings distributed_tracing: enabled: true - error_collector: - ignore_errors: 'ActionController::RoutingError' - # To disable the agent regardless of other settings, uncomment the following: agent_enabled: <%= ENV['NEW_RELIC_LICENSE_KEY'].present? && ENV.fetch('NEW_RELIC_AGENT_ENABLED', true) %>