Compare commits

...
Author SHA1 Message Date
aakashb95 b6aea9e0da add: mailers to admins to update openai key 2026-01-19 13:17:43 +05:30
aakashb95 a801eb8c44 fix: ui for errors in byok 2026-01-19 13:17:28 +05:30
aakashb95 4019ccd678 fix: do not log user facing errors to sentry 2026-01-19 13:17:13 +05:30
12 changed files with 142 additions and 46 deletions
@@ -8,6 +8,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
def update def update
@hook.update!(permitted_params.slice(:status, :settings)) @hook.update!(permitted_params.slice(:status, :settings))
@hook.reauthorized! if @hook.reauthorization_required?
end end
def process_event def process_event
@@ -18,7 +19,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
if response.nil? if response.nil?
render json: { message: nil } render json: { message: nil }
elsif response[:error] elsif response[:error]
render json: { error: response[:error] }, status: :unprocessable_entity render json: { error: response[:error], error_type: response[:error_type] }, status: :unprocessable_entity
else else
render json: { message: response[:message] } render json: { message: response[:message] }
end end
+23 -2
View File
@@ -8,6 +8,7 @@ import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import OpenAPI from 'dashboard/api/integrations/openapi'; import OpenAPI from 'dashboard/api/integrations/openapi';
import { frontendURL } from 'dashboard/helper/URLHelper';
/** /**
* Cleans and normalizes a list of labels. * Cleans and normalizes a list of labels.
@@ -155,6 +156,8 @@ export function useAI() {
} }
}; };
const accountId = computed(() => getters.getCurrentAccountId.value);
/** /**
* Processes an AI event, such as rephrasing content. * Processes an AI event, such as rephrasing content.
* @param {string} [type='rephrase'] - The type of AI event to process. * @param {string} [type='rephrase'] - The type of AI event to process.
@@ -173,11 +176,29 @@ export function useAI() {
} = result; } = result;
return generatedMessage; return generatedMessage;
} catch (error) { } catch (error) {
const errorData = error.response.data.error; const errorData = error.response?.data;
const errorMessage = const errorMessage =
errorData?.error?.message || errorData?.error?.message ||
errorData?.error ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'); t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
const errorType = errorData?.error_type;
if (errorType === 'rate_limit') {
useAlert(t('INTEGRATION_SETTINGS.OPEN_AI.RATE_LIMIT_ERROR'), {
duration: 5000,
});
} else if (errorType === 'auth') {
useAlert(t('INTEGRATION_SETTINGS.OPEN_AI.AUTH_ERROR'), {
type: 'link',
to: frontendURL(
`accounts/${accountId.value}/settings/integrations/openai`
),
message: t('INTEGRATION_SETTINGS.OPEN_AI.GO_TO_SETTINGS'),
duration: 5000,
});
} else {
useAlert(errorMessage);
}
return ''; return '';
} }
}; };
@@ -57,6 +57,14 @@ export const useIntegrationHook = integrationId => {
return integrationType.value === 'single'; return integrationType.value === 'single';
}); });
/**
* Whether any hook needs reauthorization (e.g., API key issues)
* @type {import('vue').ComputedRef<boolean>}
*/
const hookNeedsReauthorization = computed(() => {
return integration.value.hooks?.some(hook => hook.reauthorization_required);
});
return { return {
integration, integration,
integrationType, integrationType,
@@ -64,5 +72,6 @@ export const useIntegrationHook = integrationId => {
isIntegrationSingle, isIntegrationSingle,
isHookTypeInbox, isHookTypeInbox,
hasConnectedHooks, hasConnectedHooks,
hookNeedsReauthorization,
}; };
}; };
@@ -55,8 +55,13 @@
"DISCONNECT": { "DISCONNECT": {
"BUTTON_TEXT": "Disconnect" "BUTTON_TEXT": "Disconnect"
}, },
"REAUTHORIZE": {
"BUTTON_TEXT": "Disconnect",
"DESCRIPTION": "Your API key may be invalid or your credit balance may be exhausted. Please disconnect and reconnect with a valid API key.",
"OPENAI_LINK": "Check your OpenAI dashboard"
},
"SIDEBAR_DESCRIPTION": { "SIDEBAR_DESCRIPTION": {
"DIALOGFLOW": "Dialogflow is a natural language processing platform for building conversational interfaces. Integrating it with {installationName} lets bots handle queries first and transfer them to agents when needed. It helps qualify leads and reduce agent workload by answering FAQs. To add Dialogflow, create a Service Account in Google Console and share the credentials. Refer to the docs for details" "DIALOGFLOW": "Dialogflow is a natural language processing platform for building conversational interfaces. Integrating it with {installationName} lets bots handle queries first and transfer them to agents when needed. It helps qualify leads and reduce agent workload by answering FAQs. To add Dialogflow, create a Service Account in Google Console and share the credentials. Refer to the docs for details"
} }
} }
} }
@@ -184,7 +184,10 @@
"GENERATING": "Generating...", "GENERATING": "Generating...",
"CANCEL": "Cancel" "CANCEL": "Cancel"
}, },
"GENERATE_ERROR": "There was an error processing the content, please verify your OpenAI API key and try again" "GENERATE_ERROR": "Could not process request. Check your API key, credits, or rate limits.",
"RATE_LIMIT_ERROR": "Rate limit exceeded. Check your OpenAI usage tier.",
"AUTH_ERROR": "Could not authenticate. Check your API key or credits.",
"GO_TO_SETTINGS": "Click here to update"
}, },
"DELETE": { "DELETE": {
"BUTTON_TEXT": "Delete", "BUTTON_TEXT": "Delete",
@@ -1,8 +1,9 @@
<script setup> <script setup>
import { defineProps, defineEmits } from 'vue'; import { useI18n } from 'vue-i18n';
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook'; import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
import { useBranding } from 'shared/composables/useBranding'; import { useBranding } from 'shared/composables/useBranding';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
const props = defineProps({ const props = defineProps({
integrationId: { integrationId: {
@@ -13,54 +14,68 @@ const props = defineProps({
defineEmits(['add', 'delete']); defineEmits(['add', 'delete']);
const { integration, hasConnectedHooks } = useIntegrationHook( const { t } = useI18n();
props.integrationId
); const { integration, hasConnectedHooks, hookNeedsReauthorization } =
useIntegrationHook(props.integrationId);
const { replaceInstallationName } = useBranding(); const { replaceInstallationName } = useBranding();
</script> </script>
<template> <template>
<div <div class="flex flex-col gap-4">
class="outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow flex-grow overflow-auto p-4" <Banner v-if="hookNeedsReauthorization" color="ruby">
> {{ t('INTEGRATION_APPS.REAUTHORIZE.DESCRIPTION') }}
<div class="flex items-center justify-center"> <a
<div class="flex h-16 w-16 items-center justify-center"> href="https://platform.openai.com/account/api-keys"
<img target="_blank"
:src="`/dashboard/images/integrations/${integrationId}.png`" rel="noopener noreferrer"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2" class="underline font-medium"
/> >
<img {{ t('INTEGRATION_APPS.REAUTHORIZE.OPENAI_LINK') }}
:src="`/dashboard/images/integrations/${integrationId}-dark.png`" </a>
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2" </Banner>
/> <div
</div> class="outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow flex-grow overflow-auto p-4"
<div class="flex flex-col justify-center m-0 mx-4 flex-1"> >
<h3 class="mb-1 text-xl font-medium text-n-slate-12"> <div class="flex items-center justify-center">
{{ integration.name }} <div class="flex h-16 w-16 items-center justify-center">
</h3> <img
<p class="text-n-slate-11 text-sm leading-6"> :src="`/dashboard/images/integrations/${integrationId}.png`"
{{ replaceInstallationName(integration.description) }} class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
</p> />
</div> <img
<div class="flex justify-center items-center mb-0 w-[15%]"> :src="`/dashboard/images/integrations/${integrationId}-dark.png`"
<div v-if="hasConnectedHooks"> class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
<div @click="$emit('delete', integration.hooks[0])"> />
</div>
<div class="flex flex-col justify-center m-0 mx-4 flex-1">
<h3 class="mb-1 text-xl font-medium text-n-slate-12">
{{ integration.name }}
</h3>
<p class="text-n-slate-11 text-sm leading-6">
{{ replaceInstallationName(integration.description) }}
</p>
</div>
<div class="flex justify-center items-center mb-0 w-[15%]">
<div v-if="hasConnectedHooks">
<div @click="$emit('delete', integration.hooks[0])">
<Button
ruby
faded
:label="$t('INTEGRATION_APPS.DISCONNECT.BUTTON_TEXT')"
/>
</div>
</div>
<div v-else>
<Button <Button
ruby blue
faded faded
:label="$t('INTEGRATION_APPS.DISCONNECT.BUTTON_TEXT')" :label="$t('INTEGRATION_APPS.CONNECT.BUTTON_TEXT')"
@click="$emit('add')"
/> />
</div> </div>
</div> </div>
<div v-else>
<Button
blue
faded
:label="$t('INTEGRATION_APPS.CONNECT.BUTTON_TEXT')"
@click="$emit('add')"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -9,4 +9,10 @@ class AdministratorNotifications::IntegrationsNotificationMailer < Administrator
subject = 'Your Dialogflow integration was disconnected' subject = 'Your Dialogflow integration was disconnected'
send_notification(subject) send_notification(subject)
end end
def openai_disconnect
subject = 'Your OpenAI integration needs attention'
action_url = settings_url('integrations/openai')
send_notification(subject, action_url: action_url)
end
end end
+2
View File
@@ -49,6 +49,8 @@ module Reauthorizable
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).slack_disconnect.deliver_later AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).slack_disconnect.deliver_later
elsif dialogflow? elsif dialogflow?
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).dialogflow_disconnect.deliver_later AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).dialogflow_disconnect.deliver_later
elsif openai?
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).openai_disconnect.deliver_later
end end
end end
+4
View File
@@ -60,6 +60,10 @@ class Integrations::Hook < ApplicationRecord
app_id == 'notion' app_id == 'notion'
end end
def openai?
app_id == 'openai'
end
def disable def disable
update(status: 'disabled') update(status: 'disabled')
end end
@@ -4,6 +4,7 @@ json.status resource.enabled?
json.inbox resource.inbox&.slice(:id, :name) json.inbox resource.inbox&.slice(:id, :name)
json.account_id resource.account_id json.account_id resource.account_id
json.hook_type resource.hook_type json.hook_type resource.hook_type
json.reauthorization_required resource.reauthorization_required?
json.settings resource.settings if Current.account_user&.administrator? json.settings resource.settings if Current.account_user&.administrator?
json.reference_id resource.reference_id if Current.account_user&.administrator? json.reference_id resource.reference_id if Current.account_user&.administrator?
@@ -0,0 +1,9 @@
<p>Hello,</p>
<p>Your OpenAI integration needs attention. This could be due to an expired or invalid API key, or your OpenAI credit balance may be exhausted.</p>
<p>To continue using AI features, please update your API key in the integration settings.</p>
<p>
Click <a href="{{action_url}}">here</a> to update your API key.
</p>
+22 -2
View File
@@ -96,6 +96,9 @@ class Integrations::LlmBaseService
end end
end end
RATE_LIMIT_ERRORS = [RubyLLM::RateLimitError].freeze
AUTH_ERRORS = [RubyLLM::UnauthorizedError, RubyLLM::PaymentRequiredError, RubyLLM::ForbiddenError].freeze
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']
@@ -104,11 +107,26 @@ class Integrations::LlmBaseService
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 *RATE_LIMIT_ERRORS => e
handle_rate_limit_error(e, messages)
rescue *AUTH_ERRORS => e
handle_auth_error(e, messages)
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: hook.account).capture_exception ChatwootExceptionTracker.new(e, account: hook.account).capture_exception
build_error_response_from_exception(e, messages) build_error_response_from_exception(e, messages)
end end
def handle_rate_limit_error(error, messages)
Rails.logger.warn "[LLM] Rate limit error for hook #{hook.id}: #{error.message}"
build_error_response_from_exception(error, messages, error_type: 'rate_limit')
end
def handle_auth_error(error, messages)
Rails.logger.warn "[LLM] Auth error for hook #{hook.id}: #{error.class} - #{error.message}"
hook.authorization_error!
build_error_response_from_exception(error, messages, error_type: 'auth')
end
def setup_chat_with_messages(chat, messages) def setup_chat_with_messages(chat, messages)
apply_system_instructions(chat, messages) apply_system_instructions(chat, messages)
response = send_conversation_messages(chat, messages) response = send_conversation_messages(chat, messages)
@@ -163,7 +181,9 @@ class Integrations::LlmBaseService
} }
end end
def build_error_response_from_exception(error, messages) def build_error_response_from_exception(error, messages, error_type: nil)
{ error: error.message, request_messages: messages } response = { error: error.message, request_messages: messages }
response[:error_type] = error_type if error_type
response
end end
end end