Maintenance: Improving API-Token management for Zammad AI

Co-authored-by: Mantas Masalskis <mm@zammad.com>
Co-authored-by: Benjamin Scharf <bs@zammad.com>
This commit is contained in:
Mantas Masalskis 2025-10-28 14:43:44 +02:00 committed by Benjamin Scharf
parent bdb6758daf
commit 52f6589a61
76 changed files with 651 additions and 875 deletions

View file

@ -15,11 +15,8 @@ class App.ControllerAIFeatureBase extends App.ControllerSubContent
@controllerBind('config_update', @aiProviderConfigHasChanged)
missingProvider: ->
_.isEmpty(App.Config.get('ai_provider'))
showAlert: =>
@missingProvider()
showAlert: ->
!App.Config.get('ai_provider')
renderAlert: =>
@el.find('.js-missingProviderAlert').remove()

View file

@ -20,7 +20,6 @@ class ChannelAiProvider extends App.ControllerTabs
@render()
class AiProviderSettings extends App.Controller
@requiredPermission: 'admin.ai_provider'
description : __('This service allows you to connect Zammad with an AI provider.')
@ -112,27 +111,13 @@ class ProviderForm extends App.Controller
constructor: (content) ->
super
@providers = @activeProviders()
@providers = App.Config.get('AIProviders')
@sortedProviders = @getSortedProviderOptions()
@render(content)
activeProviders: ->
allProviders = App.Config.get('AIProviders')
Object.entries(allProviders)
.filter(([_, provider]) ->
if typeof provider.active is 'function'
provider.active()
else
provider.active
)
.reduce((acc, [key, provider]) ->
acc[key] = provider
acc
, {})
getProviderOptions: ->
getSortedProviderOptions: ->
Object
.entries(@providers)
.sort(([_, a], [__, b]) -> a.prio - b.prio)
@ -207,7 +192,7 @@ class ProviderForm extends App.Controller
name: 'provider'
display: __('Provider')
tag: 'select'
options: @getProviderOptions()
options: @sortedProviders
null: true
nulloption: true
value: provider
@ -219,14 +204,21 @@ class ProviderForm extends App.Controller
return result if !currentProvider
providerParams = if App.Setting.get('ai_provider') == provider then params else {}
savedProvider = App.Setting.get('ai_provider_config')['provider']
providerParams = if savedProvider == provider then params else {}
fields = @getInputFields(currentProvider, providerParams)
result.concat _.map(currentProvider.fields, (field) -> fields[field])
currentProviderFields = if typeof currentProvider.fields is 'function'
currentProvider.fields()
else
currentProvider.fields
result.concat _.map(currentProviderFields, (field) -> fields[field])
render: (provider) ->
config = App.Setting.get('ai_provider_config') || {}
current_provider = if provider != undefined then provider else App.Setting.get('ai_provider')
current_provider = if provider != undefined then provider else config['provider']
configure_attributes = @providerConfiguration(current_provider, config)
@ -244,8 +236,6 @@ class ProviderForm extends App.Controller
$('select[name=provider]').on('change', (e) =>
@render($(e.target).val()))
update: (e) =>
e.preventDefault()
@ -271,14 +261,16 @@ class ProviderForm extends App.Controller
@validateAndSave(params)
validateAndSave: (params) ->
provider = params.provider
has_provider = !_.isEmpty(params.provider)
if !has_provider
delete params.provider
delete params.provider
if !params.model || params.model.trim() == ''
delete params.model
config = params
App.Setting.set('ai_provider', provider, done: -> App.Setting.set('ai_provider_config', config, notify: true))
App.Setting.set('ai_provider_config', params, done: ->
App.Setting.set('ai_provider', has_provider, notify: true)
)
App.Config.set('Provider', { prio: 1000, name: __('Provider'), parent: '#ai', target: '#ai/provider', controller: ChannelAiProvider, permission: ['admin.ai_provider'] }, 'NavBarAdmin')

View file

@ -51,8 +51,8 @@ class TextTool extends App.ControllerAIFeatureBase
@genericController.paginate(@page || 1, params)
showAlert: =>
App.Config.get('ai_assistance_text_tools') and @missingProvider()
showAlert: ->
App.Config.get('ai_assistance_text_tools') and !App.Config.get('ai_provider')
pageHeaderTitle: =>
@$('.page-header-title')

View file

@ -27,8 +27,8 @@ class App.TicketSummary extends App.ControllerAIFeatureBase
field.prop('checked', value)
)
showAlert: =>
App.Config.get('ai_assistance_ticket_summary') && @missingProvider()
showAlert: ->
App.Config.get('ai_assistance_ticket_summary') && !App.Config.get('ai_provider')
render: =>
service_config = App.Setting.get('ai_assistance_ticket_summary_config') || {}

View file

@ -67,7 +67,7 @@ class App.WidgetTextTools extends App.Controller
$(element).data().plugin_texttools.collection = @all
@enabled: ->
App.Config.get('ai_assistance_text_tools') and not _.isEmpty(App.Config.get('ai_provider'))
App.Config.get('ai_assistance_text_tools') and App.Config.get('ai_provider')
@availableTextTools: (ce) ->
$(ce.element).data().plugin_texttools?.collection or []

View file

@ -4,6 +4,5 @@ App.Config.set('anthropic', {
label: __('Anthropic')
prio: 4000
fields: ['token', 'model']
active: true
default_model: 'claude-3-7-sonnet-latest'
}, 'AIProviders')

View file

@ -3,5 +3,4 @@ App.Config.set('azure', {
label: __('Azure AI')
prio: 5000
fields: ['url_completions', 'token'] # TODO: Add url_embeddings when needed.
active: true
}, 'AIProviders')

View file

@ -4,6 +4,5 @@ App.Config.set('mistral', {
label: __('Mistral AI')
prio: 6000
fields: ['token', 'model']
active: true
default_model: 'mistral-medium-latest'
}, 'AIProviders')

View file

@ -4,6 +4,5 @@ App.Config.set('ollama', {
label: __('Ollama')
prio: 3000
fields: ['url', 'model']
active: true
default_model: 'llama3.2'
}, 'AIProviders')

View file

@ -4,6 +4,5 @@ App.Config.set('open_ai', {
label: __('OpenAI')
prio: 2000
fields: ['token', 'model']
active: true
default_model: 'gpt-4.1'
}, 'AIProviders')

View file

@ -2,6 +2,9 @@ App.Config.set('zammad_ai', {
key: 'zammad_ai'
label: __('Zammad AI')
prio: 1000
fields: ['token']
active: -> App.Config.get('system_online_service') || App.Config.get('developer_mode')
fields: ->
if App.Config.get('system_online_service') || App.Config.get('developer_mode')
[]
else
['token']
}, 'AIProviders')

View file

@ -183,7 +183,7 @@ describe('basic toolbar testing', () => {
it('hides feature if flag is not set', async () => {
mockApplicationConfig({
ai_assistance_text_tools: false,
ai_provider: 'openai',
ai_provider: true,
})
mockPermissions(['ticket.agent'])
@ -205,7 +205,7 @@ describe('basic toolbar testing', () => {
it('hides the feature if user is customer', async () => {
mockApplicationConfig({
ai_assistance_text_tools: true,
ai_provider: 'openai',
ai_provider: true,
})
mockPermissions(['ticket.customer'])

View file

@ -78,7 +78,7 @@ describe('Ticket detail view - Ticket summary', () => {
})
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: true,
@ -145,7 +145,7 @@ describe('Ticket detail view - Ticket summary', () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: true,
@ -187,7 +187,7 @@ describe('Ticket detail view - Ticket summary', () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: true,
@ -231,7 +231,7 @@ describe('Ticket detail view - Ticket summary', () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: true,
@ -292,7 +292,7 @@ describe('Ticket detail view - Ticket summary', () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
generate_on: EnumTicketSummaryGeneration.OnTicketDetailOpening,
@ -327,7 +327,7 @@ describe('Ticket detail view - Ticket summary', () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: false,
})
@ -358,7 +358,7 @@ describe('Ticket detail view - Ticket summary', () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
generate_on: EnumTicketSummaryGeneration.OnTicketDetailOpening,
@ -388,7 +388,7 @@ describe('Ticket detail view - Ticket summary', () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
generate_on: EnumTicketSummaryGeneration.OnTicketDetailOpening,
@ -444,7 +444,7 @@ describe('Ticket detail view - Ticket summary', () => {
},
})
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: true,
@ -485,7 +485,7 @@ describe('Ticket detail view - Ticket summary', () => {
})
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: true,
@ -514,7 +514,7 @@ describe('Ticket detail view - Ticket summary', () => {
it('shows no ai provider is selected', async () => {
mockApplicationConfig({
ai_provider: '',
ai_provider: false,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: true,
@ -538,7 +538,7 @@ describe('Ticket detail view - Ticket summary', () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: false,
})
@ -556,7 +556,7 @@ describe('Ticket detail view - Ticket summary', () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: true,
@ -583,7 +583,7 @@ describe('Ticket detail view - Ticket summary', () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: true,
@ -612,7 +612,7 @@ describe('Ticket detail view - Ticket summary', () => {
mockPermissions(['ticket.agent'])
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: true,

View file

@ -123,7 +123,7 @@ const ticketAIAssistanceSummarizeMock = {
describe('TicketSidebarSummary', () => {
it('displays correctly', async () => {
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: true,
@ -181,7 +181,7 @@ describe('TicketSidebarSummary', () => {
it('does not display headings which are disabled,', async () => {
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: false,
@ -261,7 +261,7 @@ describe('TicketSidebarSummary', () => {
})
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: true,
@ -289,7 +289,7 @@ describe('TicketSidebarSummary', () => {
})
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
ai_assistance_ticket_summary_config: {
open_questions: true,
@ -323,7 +323,7 @@ describe('TicketSidebarSummary', () => {
it('shows message that user has provided already feedback', async () => {
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
})
@ -354,7 +354,7 @@ describe('TicketSidebarSummary', () => {
const runId = convertToGraphQLId('AIAnalyticsRun', 12345)
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
})
@ -390,7 +390,7 @@ describe('TicketSidebarSummary', () => {
const runId = convertToGraphQLId('AIAnalyticsRun', 12345)
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
})
@ -423,7 +423,7 @@ describe('TicketSidebarSummary', () => {
const runId = convertToGraphQLId('AIAnalyticsRun', 12345)
mockApplicationConfig({
ai_provider: 'zammad_ai',
ai_provider: true,
ai_assistance_ticket_summary: true,
})

View file

@ -39,7 +39,7 @@ vi.mock('prosemirror-model', () => {
}
})
describe.todo('keyboard interactions', () => {
describe('keyboard interactions', () => {
it('can use arrows to traverse toolbar', async () => {
const view = renderComponent(FieldEditorActionBar, {
props: {
@ -223,7 +223,7 @@ describe('basic toolbar testing', () => {
it('hides feature if flag is not set', async () => {
mockApplicationConfig({
ai_assistance_text_tools: false,
ai_provider: 'openai',
ai_provider: true,
})
mockPermissions(['ticket.agent'])
@ -245,7 +245,7 @@ describe('basic toolbar testing', () => {
it('hides the feature if user is customer', async () => {
mockApplicationConfig({
ai_assistance_text_tools: true,
ai_provider: 'openai',
ai_provider: true,
})
mockPermissions(['ticket.customer'])
@ -289,7 +289,7 @@ describe('basic toolbar testing', () => {
it.todo('can use custom text tools', async () => {
mockApplicationConfig({
ai_assistance_text_tools: true,
ai_provider: 'openai',
ai_provider: true,
})
mockAiAssistanceTextToolsListQuery({

View file

@ -39,7 +39,7 @@ describe('Testing AI text tools', { retries: 2 }, () => {
mountEditor({}, ['ticket.agent'], {
ai_assistance_text_tools: true,
ai_provider: 'zammad_ai',
ai_provider: true,
})
cy.findByRole('textbox').type('Some text which should be checked.{selectall}')

View file

@ -7,7 +7,7 @@ export interface ConfigList {
ai_assistance_text_tools_fixed_instructions: string
ai_assistance_ticket_summary: boolean
ai_assistance_ticket_summary_config: unknown
ai_provider: string
ai_provider: boolean
api_password_access?: boolean | null
api_token_access?: boolean | null
auth_facebook?: boolean | null

View file

@ -14,7 +14,7 @@ class CoreWorkflow::Custom::AdminGroupSummaryGeneration < CoreWorkflow::Custom::
end
def visibility
return 'show' if Setting.get('ai_provider').present? && Setting.get('ai_assistance_ticket_summary')
return 'show' if Setting.get('ai_provider') && Setting.get('ai_assistance_ticket_summary')
'remove'
end

View file

@ -1,23 +0,0 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class Setting::Validation::AIProvider < Setting::Validation::Base
PROVIDERS = AI::Provider.list.map { |provider| provider.name.demodulize.underscore }.freeze
def run
return result_success if value.blank?
msg = validate_provider
return result_failed(msg) if !msg.nil?
result_success
end
private
def validate_provider
return __('AI provider is not supported') if PROVIDERS.exclude?(value)
nil
end
end

View file

@ -3,61 +3,77 @@
class Setting::Validation::AIProviderConfig < Setting::Validation::Base
attr_reader :provider
ERROR_MESSAGE_OLLAMA = __('AI provider Ollama URL is not set').freeze
ERROR_MESSAGE_AZURE = __('AI provider Azure configuration is incomplete').freeze
ERROR_MESSAGE_TOKEN = __('AI provider token is not set').freeze
class AIProviderConfigError < StandardError; end
def initialize(record)
super
@provider = Setting.get('ai_provider')
@provider = value[:provider]
end
def run
return result_success if value.blank? || provider.blank?
return result_success if value.blank?
msg = verify_configuration
return result_failed(msg) if !msg.nil?
verify_configuration
result_success
rescue AIProviderConfigError => e
result_failed(e.message)
end
private
def verify_configuration
msg = required_attributes
return msg if !msg.nil?
validate_provider
required_attributes
accessible
end
def required_attributes
case provider
when 'ollama'
return __('AI provider Ollama URL is not set') if value['url'].blank?
required_attributes_ollama
when 'azure'
return __('AI provider Azure configuration is incomplete') if !required_attributes_azure
required_attributes_azure
when 'zammad_ai'
required_attributes_zammad
else
return __('AI provider token is not set') if value['token'].blank?
required_attributes_token
end
nil
end
def accessible
provider_class = AI::Provider.by_name(provider)
provider_class.ping!(value)
nil
rescue => e
__("AI provider is not accessible: #{e.message}")
end
def required_attributes_azure
return false if value['url_completions'].blank?
raise AIProviderConfigError, ERROR_MESSAGE_AZURE if %w[url_completions token].any? { |key| value[key].blank? }
end
# TODO: Enable it when needed.
# return false if value['url_embeddings'].blank?
def required_attributes_ollama
raise AIProviderConfigError, ERROR_MESSAGE_OLLAMA if value['url'].blank?
end
return false if value['token'].blank?
def required_attributes_token
raise AIProviderConfigError, ERROR_MESSAGE_TOKEN if value['token'].blank?
end
true
def required_attributes_zammad
return if Setting.get('system_online_service') || Setting.get('developer_mode')
required_attributes_token
end
def validate_provider
raise AIProviderConfigError, __('AI provider is missing') if provider.blank?
raise AIProviderConfigError, __('AI provider is not supported') if !AI::Provider.by_name(provider)
end
def accessible
AI::Provider
.by_name(provider)
.ping!(value)
rescue => e
raise AIProviderConfigError, __("AI provider is not accessible: #{e.message}")
end
end

View file

@ -10,7 +10,7 @@ module Service::AI::VectorDB
private
def embedding_size
provider = AI::Provider.by_name(Setting.get('ai_provider'))
provider = AI::Provider.current
embedding_sizes = provider.const_get(:EMBEDDING_SIZES)

View file

@ -14,7 +14,7 @@ module Service::AI::VectorDB::Item
end
def execute
embedding = AI::Provider.by_name(Setting.get('ai_provider')).new.embed(input: content)
embedding = AI::Provider.current.new.embed(input: content)
ai_vector_db.create(object_id: o_id, object_name:, content:, metadata:, embedding:)
end

View file

@ -14,7 +14,7 @@ module Service::AI::VectorDB::Item
end
def execute
embedding = AI::Provider.by_name(Setting.get('ai_provider')).new.embed(input: content)
embedding = AI::Provider.current.new.embed(input: content)
ai_vector_db.upsert(object_id: o_id, object_name:, content:, metadata:, embedding:) # rubocop:disable Rails/SkipsModelValidations
end

View file

@ -14,7 +14,7 @@ module Service::AI::VectorDB
ai_vector_db.ping!
# First we need to embed the text.
embedding = AI::Provider.by_name(Setting.get('ai_provider')).new.embed(input: text)
embedding = AI::Provider.current.new.embed(input: text)
# Then we need to search the vector database for the most similar items.
ai_vector_db.knn(embedding:, k: 2)

View file

@ -29,7 +29,8 @@ class AddAIAssistanceTicketSummarize < ActiveRecord::Migration[7.2]
authentication: true,
permission: ['admin.ai'],
validations: [
'Setting::Validation::AIProvider',
# This validaiton is now gone
# 'Setting::Validation::AIProvider',
],
},
frontend: true,

View file

@ -0,0 +1,49 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
class ChangeToAIProviderFlag < ActiveRecord::Migration[7.2]
def up
# return if it's a new setup
return if !Setting.exists?(name: 'system_init_done')
is_provider_set = Setting.get('ai_provider').present?
copy_provider_name
drop_old_provider_setting
add_new_provider_setting(is_provider_set)
end
private
def copy_provider_name
config_setting = Setting.get('ai_provider_config')
ai_provider_name = Setting.get('ai_provider')
return if ai_provider_name.blank?
config_setting[:provider] = ai_provider_name
Setting.set('ai_provider_config', config_setting, validate: false)
end
def drop_old_provider_setting
Setting
.find_by!(name: 'ai_provider')
.destroy!
end
def add_new_provider_setting(state)
Setting.create_if_not_exists(
title: 'AI provider',
name: 'ai_provider',
area: 'AI::Provider',
description: 'Defines if the AI provider is configured.',
options: {},
state:,
preferences: {
authentication: true,
permission: ['admin.ai_provider'],
},
frontend: true,
)
end
end

View file

@ -5983,15 +5983,12 @@ Setting.create_if_not_exists(
title: __('AI provider'),
name: 'ai_provider',
area: 'AI::Provider',
description: __('Stores the AI provider.'),
description: __('Defines if the AI provider is configured.'),
options: {},
state: '',
state: false,
preferences: {
authentication: true,
permission: ['admin.ai_provider'],
validations: [
'Setting::Validation::AIProvider',
],
},
frontend: true,
)

View file

@ -669,7 +669,7 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: db/seeds/settings.rb:5993
#: db/seeds/settings.rb:5990
msgid "AI Provider Config"
msgstr ""
@ -702,14 +702,18 @@ msgstr ""
msgid "AI provider"
msgstr ""
#: app/models/setting/validation/ai_provider_config.rb:35
#: app/models/setting/validation/ai_provider_config.rb:7
msgid "AI provider Azure configuration is incomplete"
msgstr ""
#: app/models/setting/validation/ai_provider_config.rb:33
#: app/models/setting/validation/ai_provider_config.rb:6
msgid "AI provider Ollama URL is not set"
msgstr ""
#: app/models/setting/validation/ai_provider_config.rb:68
msgid "AI provider is missing"
msgstr ""
#: app/controllers/ticket/summarize_controller.rb:8
#: app/graphql/gql/mutations/ticket/ai_assistance/summarize.rb:16
#: app/services/service/ai/agent/run.rb:17
@ -719,11 +723,11 @@ msgstr ""
msgid "AI provider is not configured."
msgstr ""
#: app/models/setting/validation/ai_provider.rb:19
#: app/models/setting/validation/ai_provider_config.rb:69
msgid "AI provider is not supported"
msgstr ""
#: app/models/setting/validation/ai_provider_config.rb:37
#: app/models/setting/validation/ai_provider_config.rb:8
msgid "AI provider token is not set"
msgstr ""
@ -4667,6 +4671,10 @@ msgstr ""
msgid "Defines if sipgate.io (http://www.sipgate.io) is enabled or not."
msgstr ""
#: db/seeds/settings.rb:5979
msgid "Defines if the AI provider is configured."
msgstr ""
#: db/seeds/settings.rb:5058
msgid "Defines if the GitHub (http://www.github.com) integration is enabled or not."
msgstr ""
@ -4687,7 +4695,7 @@ msgstr ""
msgid "Defines if the application is in developer mode (all users have the same password and password reset will work without email delivery)."
msgstr ""
#: db/seeds/settings.rb:6073
#: db/seeds/settings.rb:6070
msgid "Defines if the bubble menu feature of the richtext editor is enabled. Note that this setting will be ignored if the writing assistant is turned on."
msgstr ""
@ -4951,7 +4959,7 @@ msgstr ""
msgid "Defines the duration of customer activity (in seconds) on a call until the user profile dialog is shown."
msgstr ""
#: db/seeds/settings.rb:6059
#: db/seeds/settings.rb:6056
msgid "Defines the fixed instructions that guide the AI Writing Assistant on e.g. how to format its output."
msgstr ""
@ -6151,11 +6159,11 @@ msgstr ""
msgid "Enable or disable the maintenance mode of Zammad. If enabled, all non-administrators get logged out and only administrators can start a new session."
msgstr ""
#: db/seeds/settings.rb:6012
#: db/seeds/settings.rb:6009
msgid "Enable or disable the ticket summary."
msgstr ""
#: db/seeds/settings.rb:6045
#: db/seeds/settings.rb:6042
msgid "Enable or disable the writing assistant text tools."
msgstr ""
@ -10057,7 +10065,7 @@ msgstr ""
msgid "Mode"
msgstr ""
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:159
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:144
msgid "Model"
msgstr ""
@ -13221,7 +13229,7 @@ msgstr ""
msgid "Rewrite complex section and make it easy to understand"
msgstr ""
#: db/seeds/settings.rb:6070
#: db/seeds/settings.rb:6067
msgid "Richtext Bubble Menu"
msgstr ""
@ -14956,14 +14964,10 @@ msgstr ""
msgid "Store"
msgstr ""
#: db/seeds/settings.rb:5996
#: db/seeds/settings.rb:5993
msgid "Stores the AI provider configuration."
msgstr ""
#: db/seeds/settings.rb:5979
msgid "Stores the AI provider."
msgstr ""
#: db/seeds/settings.rb:5085
msgid "Stores the GitHub configuration."
msgstr ""
@ -14972,7 +14976,7 @@ msgstr ""
msgid "Stores the GitLab configuration."
msgstr ""
#: db/seeds/settings.rb:6026
#: db/seeds/settings.rb:6023
msgid "Stores the ticket summarization options (e.g. which content is visible)."
msgstr ""
@ -15424,7 +15428,7 @@ msgstr ""
msgid "The 24 hour customer service window is now closed, no further WhatsApp messages can be sent."
msgstr ""
#: lib/monitoring_helper/health_checker/ai_provider_accessible.rb:17
#: lib/monitoring_helper/health_checker/ai_provider_accessible.rb:15
msgid "The AI Provider is not accessible."
msgstr ""
@ -15781,7 +15785,7 @@ msgstr ""
msgid "The divider between TicketHook and ticket number. E. g. ': '."
msgstr ""
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:93
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:92
#: app/assets/javascripts/app/lib/app_post/utils.coffee:1688
msgid "The download could not be started. Please try again later."
msgstr ""
@ -16320,7 +16324,7 @@ msgstr ""
msgid "The required value 'group_id' is missing."
msgstr ""
#: lib/ai/provider.rb:50
#: lib/ai/provider.rb:59
msgid "The response could not be processed."
msgstr ""
@ -17060,11 +17064,11 @@ msgstr ""
msgid "This service allows you to connect %s with %s."
msgstr ""
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:26
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:25
msgid "This service allows you to connect Zammad with an AI provider."
msgstr ""
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:44
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:43
msgid "This service allows you to download feedback agents provide on AI features and error details about failed AI requests."
msgstr ""
@ -17354,11 +17358,11 @@ msgstr ""
#: app/assets/javascripts/app/controllers/_ai/ticket_summary.coffee:2
#: app/assets/javascripts/app/views/ai/ticket_summary.jst.eco:7
#: db/seeds/permissions.rb:227
#: db/seeds/settings.rb:6009
#: db/seeds/settings.rb:6006
msgid "Ticket Summary"
msgstr ""
#: db/seeds/settings.rb:6023
#: db/seeds/settings.rb:6020
msgid "Ticket Summary Config"
msgstr ""
@ -17821,7 +17825,7 @@ msgstr ""
msgid "Toggle the overlay"
msgstr ""
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:148
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:133
#: app/assets/javascripts/app/controllers/api.coffee:11
#: app/assets/javascripts/app/views/channel/sms_account_overview.jst.eco:48
#: app/models/channel/driver/sms/massenversand.rb:44
@ -18155,7 +18159,7 @@ msgstr ""
msgid "UID Field"
msgstr ""
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:170
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:155
#: app/assets/javascripts/app/views/api.jst.eco:63
#: app/assets/javascripts/app/views/integration/check_mk.jst.eco:12
#: app/assets/javascripts/app/views/integration/cti.jst.eco:12
@ -18172,11 +18176,11 @@ msgstr ""
msgid "URL (AJAX endpoint)"
msgstr ""
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:180
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:165
msgid "URL (Completions)"
msgstr ""
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:190
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:175
msgid "URL (Embeddings)"
msgstr ""
@ -19268,11 +19272,11 @@ msgstr ""
#: app/assets/javascripts/app/controllers/_ai/text_tool.coffee:16
#: app/frontend/shared/components/Form/fields/FieldEditor/features/ai-assistant-text-tools/AiAssistantLoadingBanner/AiAssistantLoadingBanner.vue:35
#: db/seeds/permissions.rb:233
#: db/seeds/settings.rb:6042
#: db/seeds/settings.rb:6039
msgid "Writing Assistant"
msgstr ""
#: db/seeds/settings.rb:6056
#: db/seeds/settings.rb:6053
msgid "Writing Assistant Fixed Instructions"
msgstr ""

View file

@ -23,14 +23,23 @@ class AI::Provider
end
class << self
def list
@list ||= descendants.sort_by(&:name)
end
def by_name(name)
"AI::Provider::#{name.classify}".safe_constantize
end
def by_config(config)
provider_name = config&.dig(:provider)
return if provider_name.blank?
by_name(provider_name)
end
def current
return nil if !Setting.get('ai_provider')
by_config(Setting.get('ai_provider_config'))
end
def ping!(_config)
raise 'not implemented'
end

View file

@ -22,7 +22,7 @@ class AI::Provider::ZammadAI < AI::Provider
open_timeout: 4,
read_timeout: 60,
verify_ssl: true,
bearer_token: config[:token],
bearer_token: self.class.token(config),
total_timeout: 60,
json: true,
log: {
@ -49,7 +49,7 @@ class AI::Provider::ZammadAI < AI::Provider
open_timeout: 4,
read_timeout: 60,
verify_ssl: true,
bearer_token: config[:token],
bearer_token: token(config),
total_timeout: 60,
json: true,
log: {
@ -68,6 +68,10 @@ class AI::Provider::ZammadAI < AI::Provider
ENV['ZAMMAD_AI_API_URL'] || config[:url] || ZAMMAD_AI_API_BASE_URL
end
def self.token(config)
config[:token].presence || ENV['ZAMMAD_AI_TOKEN']
end
private
def extract_response_metadata(data)

View file

@ -111,7 +111,7 @@ class AI::Service
end
def provider_name
@provider_name ||= Setting.get('ai_provider')
@provider_name ||= Setting.get('ai_provider_config')&.dig(:provider)
end
def save_result(result, ai_analytics_run:)

View file

@ -5,14 +5,12 @@ module MonitoringHelper
class AIProviderAccessible < Backend
def run_health_check
provider = Setting.get('ai_provider')
return if provider.blank?
return if !Setting.get('ai_provider')
provider_config = Setting.get('ai_provider_config')
return if provider_config.blank?
begin
AI::Provider.by_name(provider).ping!(provider_config)
AI::Provider.by_config(provider_config).ping!(provider_config)
rescue AI::Provider::ResponseError
response.issues.push __('The AI Provider is not accessible.')
end

View file

@ -0,0 +1,78 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe ChangeToAIProviderFlag, type: :db_migration do
before do
Setting
.find_by!(name: 'ai_provider')
.destroy!
Setting.new(
title: __('AI provider'),
name: 'ai_provider',
area: 'AI::Provider',
description: __('Stores the AI provider.'),
options: {},
state: '',
preferences: {
authentication: true,
permission: ['admin.ai_provider'],
validations: [
'Setting::Validation::AIProvider',
],
},
frontend: true,
).save(validate: false)
end
shared_examples 'migrates ai provider setting' do
it 'setting does not have validations anymore' do
expect { migrate }
.to change { Setting.find_by(name: 'ai_provider').preferences[:validations] }
.to(nil)
end
it 'Changes description' do
expect { migrate }
.to change { Setting.find_by(name: 'ai_provider').description }
end
end
context 'when provider was configured' do
before do
Setting.set('ai_provider', 'example', validate: false)
Setting.set('ai_provider_config', { test: 'value' }, validate: false)
end
it 'moves provider to ai_provider_config setting' do
expect { migrate }
.to change { Setting.get('ai_provider_config') }
.to include(provider: 'example', test: 'value')
end
it 'sets ai_provider flag to true' do
expect { migrate }
.to change { Setting.get('ai_provider') }
.to(true)
end
include_examples 'migrates ai provider setting'
end
context 'when provider was empty' do
it 'keeps ai_provider_config setting empty' do
expect { migrate }
.not_to change { Setting.get('ai_provider_config') }
.from({})
end
it 'sets ai_provider flag to false' do
expect { migrate }
.to change { Setting.get('ai_provider') }
.to(false)
end
include_examples 'migrates ai provider setting'
end
end

View file

@ -37,7 +37,7 @@ RSpec.describe Gql::Mutations::AIAssistance::TextTools::Run, :aggregate_failures
allow(AI::Provider::ZammadAI).to receive(:ping!).and_return(true)
Setting.set('ai_assistance_text_tools', true)
Setting.set('ai_provider', 'zammad_ai')
setup_ai_provider
allow_any_instance_of(AI::Service::TextTool)
.to receive(:execute)

View file

@ -42,7 +42,7 @@ RSpec.describe Gql::Mutations::Ticket::AIAssistance::Summarize, :aggregate_failu
allow(AI::Provider::ZammadAI).to receive(:ping!).and_return(true)
Setting.set('ai_assistance_ticket_summary', true)
Setting.set('ai_provider', 'zammad_ai')
setup_ai_provider
ticket_article

View file

@ -42,7 +42,7 @@ RSpec.describe Gql::Subscriptions::Ticket::AIAssistance::SummaryUpdates, authent
allow(AI::Provider::ZammadAI).to receive(:ping!).and_return(true)
Setting.set('ai_assistance_ticket_summary', true)
Setting.set('ai_provider', 'zammad_ai')
setup_ai_provider
gql.execute(subscription, variables: variables, context: { channel: mock_channel })
end

View file

@ -16,7 +16,8 @@ RSpec.describe AI::Provider::Anthropic, required_envs: %w[ANTHROPIC_API_KEY], us
Setting.set('ai_provider', 'anthropic')
Setting.set('ai_provider_config', {
token: ENV['ANTHROPIC_API_KEY'],
token: ENV['ANTHROPIC_API_KEY'],
provider: 'anthropic',
})
end

View file

@ -15,14 +15,11 @@ RSpec.describe AI::Provider::Azure, required_envs: %w[AZURE_TOKEN AZURE_URL_COMP
setting = Setting.find_by(name: 'ai_provider_config')
setting.update!(preferences: {})
Setting.set('ai_provider', 'azure')
Setting.set('ai_provider_config', {
token: ENV['AZURE_TOKEN'],
url_completions: ENV['AZURE_URL_COMPLETIONS'],
# TODO: Enable it when needed.
# url_embeddings: ENV['AZURE_URL_EMBEDDINGS']
})
setup_ai_provider('azure',
token: ENV['AZURE_TOKEN'],
url_completions: ENV['AZURE_URL_COMPLETIONS'],)
# TODO: Enable it when needed.
# url_embeddings: ENV['AZURE_URL_EMBEDDINGS']
end
include_examples 'provider/ping!'

View file

@ -17,10 +17,7 @@ RSpec.describe AI::Provider::Mistral, required_envs: %w[MISTRAL_API_KEY], use_vc
setting = Setting.find_by(name: 'ai_provider_config')
setting.update!(preferences: {})
Setting.set('ai_provider', 'mistral')
Setting.set('ai_provider_config', {
token: ENV['MISTRAL_API_KEY'],
})
setup_ai_provider('mistral', token: ENV['MISTRAL_API_KEY'])
end
include_examples 'provider/ping!'

View file

@ -14,10 +14,7 @@ RSpec.describe AI::Provider::OpenAI, required_envs: %w[OPEN_AI_TOKEN], use_vcr:
setting = Setting.find_by(name: 'ai_provider_config')
setting.update!(preferences: {})
Setting.set('ai_provider', 'open_ai')
Setting.set('ai_provider_config', {
token: ENV['OPEN_AI_TOKEN'],
})
setup_ai_provider('open_ai', token: ENV['OPEN_AI_TOKEN'])
end
include_examples 'provider/ping!'

View file

@ -14,10 +14,7 @@ RSpec.describe AI::Provider::ZammadAI, required_envs: %w[ZAMMAD_AI_TOKEN], use_v
setting = Setting.find_by(name: 'ai_provider_config')
setting.update!(preferences: {})
Setting.set('ai_provider', 'zammad_ai')
Setting.set('ai_provider_config', {
token: ENV['ZAMMAD_AI_TOKEN'],
})
setup_ai_provider('zammad_ai', token: ENV['ZAMMAD_AI_TOKEN'])
end
include_examples 'provider/ping!'

View file

@ -45,16 +45,51 @@ RSpec.describe AI::Provider do
end
end
describe '.list' do
it 'returns a list of providers' do
expect(described_class.list).to eq([
AI::Provider::Anthropic,
AI::Provider::Azure,
AI::Provider::Mistral,
AI::Provider::Ollama,
AI::Provider::OpenAI,
AI::Provider::ZammadAI,
])
describe '.by_config' do
it 'returns the correct class' do
config = { provider: 'open_ai' }
expect(described_class.by_config(config)).to eq(AI::Provider::OpenAI)
end
it 'returns nil when provider is blank' do
config = {}
expect(described_class.by_config(config)).to be_nil
end
end
describe '.current' do
before do
Setting.set('ai_provider_config', config, validate: false)
Setting.set('ai_provider', flag)
end
context 'when config is provided' do
let(:config) { { provider: 'open_ai' } }
context 'when AI provider flag is true' do
let(:flag) { true }
it 'returns the correct class' do
expect(described_class.current).to eq(AI::Provider::OpenAI)
end
end
context 'when AI provider flag is false' do
let(:flag) { false }
it 'returns nil' do
expect(described_class.current).to be_nil
end
end
end
context 'when config is blank' do
let(:config) { {} }
let(:flag) { true }
it 'returns nil' do
expect(described_class.current).to be_nil
end
end
end

View file

@ -52,7 +52,7 @@ RSpec.describe AI::Service::AIAgent, :aggregate_failures do
let(:mock_result) { { 'state_id' => 1, 'priority_id' => 2 } }
before do
Setting.set('ai_provider', 'open_ai')
setup_ai_provider('open_ai')
# Mock the provider to avoid real API calls
allow(AI::Provider::OpenAI).to receive(:new).and_return(mock_provider)

View file

@ -55,10 +55,7 @@ RSpec.describe 'AI text tool result verification', :aggregate_failures, integrat
setting = Setting.find_by(name: 'ai_provider_config')
setting.update!(preferences: {})
Setting.set('ai_provider', 'zammad_ai')
Setting.set('ai_provider_config', {
token: ENV['ZAMMAD_AI_TOKEN'],
})
setup_ai_provider('zammad_ai', token: ENV['ZAMMAD_AI_TOKEN'])
end
context 'when "Rewrite complex section and make it easy to understand" is used' do
@ -88,10 +85,7 @@ RSpec.describe 'AI text tool result verification', :aggregate_failures, integrat
# context 'with Open AI provider' do
# before do
# Setting.set('ai_provider', 'open_ai')
# Setting.set('ai_provider_config', {
# token: ENV['OPEN_AI_TOKEN'],
# })
# setup_ai_provider('open_ai', token: ENV['OPEN_AI_TOKEN'])
# end
# context 'when "Summarize section to about half its current size" is used' do
@ -103,10 +97,7 @@ RSpec.describe 'AI text tool result verification', :aggregate_failures, integrat
# context 'with Mistral provider' do
# before do
# Setting.set('ai_provider', 'mistral')
# Setting.set('ai_provider_config', {
# token: ENV['MISTRAL_API_KEY'],
# })
# setup_ai_provider('mistral_ai', token: ENV['MISTRAL_API_KEY'])
# end
# context 'when "Summarize section to about half its current size" is used' do

View file

@ -32,10 +32,7 @@ You have to follow these rules:
context 'when service is executed with OpenAI as provider' do
before do
Setting.set('ai_provider', 'open_ai')
Setting.set('ai_provider_config', {
token: ENV['OPEN_AI_TOKEN'],
})
setup_ai_provider('open_ai', token: ENV['OPEN_AI_TOKEN'])
end
it 'check that grammar is correct' do
@ -46,10 +43,7 @@ You have to follow these rules:
context 'when service is executed with ZammadAI as provider' do
before do
Setting.set('ai_provider', 'zammad_ai')
Setting.set('ai_provider_config', {
token: ENV['ZAMMAD_AI_TOKEN'],
})
setup_ai_provider('zammad_ai', token: ENV['ZAMMAD_AI_TOKEN'])
end
it 'check that grammar is correct' do
@ -63,10 +57,7 @@ You have to follow these rules:
setting = Setting.find_by(name: 'ai_provider_config')
setting.update!(preferences: {})
Setting.set('ai_provider', 'zammad_ai')
Setting.set('ai_provider_config', {
token: ENV['ZAMMAD_AI_TOKEN'],
})
setup_ai_provider('zammad_ai', token: ENV['ZAMMAD_AI_TOKEN'])
end
context 'when neither prompt nor result contain paragraphs but have line breaks' do

View file

@ -36,8 +36,7 @@ RSpec.describe AI::Service do
before do
stub_const('AI::Service::PROMPT_PATH_STRING', Rails.root.join('test/data/ai/prompts/%{service}_%{type}.text.erb').to_s)
stub_const('Setting::Validation::AIProvider::PROVIDERS', %w[sample_provider])
Setting.set('ai_provider', 'sample_provider')
setup_ai_provider 'sample_provider'
end
describe '#execute' do

View file

@ -7,15 +7,8 @@ RSpec.describe MonitoringHelper::HealthChecker::AIProviderAccessible, integratio
describe '#check_health' do
context 'when AI integration is not configured' do
it 'reports no issue' do
expect(instance.check_health.issues).to be_blank
end
end
context 'when AI integration has an empty configuration' do
before do
Setting.set('ai_provider', 'open_ai')
Setting.set('ai_provider_config', {})
unset_ai_provider
end
it 'reports no issue' do
@ -28,8 +21,7 @@ RSpec.describe MonitoringHelper::HealthChecker::AIProviderAccessible, integratio
# Reset preferences to avoid validation errors.
ai_config = Setting.find_by(name: 'ai_provider_config')
ai_config.update!(preferences: {})
Setting.set('ai_provider', 'open_ai')
Setting.set('ai_provider_config', { 'token' => '123' })
setup_ai_provider('open_ai', token: '123')
end
context 'when AI provider is accessible' do

View file

@ -16,7 +16,7 @@ RSpec.describe CoreWorkflow::Custom::AdminGroupSummaryGeneration, type: :model d
before do
allow(AI::Provider::ZammadAI).to receive(:ping!).and_return(true)
Setting.set('ai_provider', 'zammad_ai')
setup_ai_provider
Setting.set('ai_assistance_ticket_summary', true)
end
@ -27,7 +27,7 @@ RSpec.describe CoreWorkflow::Custom::AdminGroupSummaryGeneration, type: :model d
context 'when settings disabled' do
before do
Setting.set('ai_provider', '')
unset_ai_provider
end
it 'does not show ticket generation field for group' do
@ -39,7 +39,7 @@ RSpec.describe CoreWorkflow::Custom::AdminGroupSummaryGeneration, type: :model d
before do
allow(AI::Provider::ZammadAI).to receive(:ping!).and_return(true)
Setting.set('ai_provider', 'zammad_ai')
setup_ai_provider
Setting.set('ai_assistance_ticket_summary', false)
end

View file

@ -2,7 +2,7 @@
require 'rails_helper'
RSpec.describe Setting::Validation::AIProviderConfig, required_envs: %w[OPEN_AI_TOKEN], use_vcr: true do
RSpec.describe Setting::Validation::AIProviderConfig do
let(:setting_name) { 'ai_provider_config' }
@ -10,56 +10,204 @@ RSpec.describe Setting::Validation::AIProviderConfig, required_envs: %w[OPEN_AI_
it 'does not raise error' do
expect { Setting.set(setting_name, {}) }.not_to raise_error
end
context 'when config is present but provider is missing' do
it 'raises error' do
expect { Setting.set(setting_name, { url: 'http://ai.example.com' }) }
.to raise_error(ActiveRecord::RecordInvalid)
end
end
end
context 'with missing provider' do
it 'does not raise error' do
expect { Setting.set(setting_name, { 'token' => nil }) }.not_to raise_error
context 'with unsupported provider' do
it 'raises error' do
expect { Setting.set(setting_name, { provider: 'unsupported' }) }
.to raise_error(ActiveRecord::RecordInvalid)
end
end
context 'when provider is ollama' do
let(:config) { { provider: 'ollama', url: } }
before do
allow(AI::Provider::Ollama).to receive(:ping!).and_return(true)
end
context 'with missing url' do
before do
Setting.set('ai_provider', 'ollama')
end
let(:url) { nil }
it 'raises error' do
expect { Setting.set(setting_name, { 'url' => nil }) }.to raise_error(ActiveRecord::RecordInvalid)
expect { Setting.set(setting_name, config) }
.to raise_error(ActiveRecord::RecordInvalid)
end
end
context 'with valid url' do
before do
Setting.set('ai_provider', 'ollama')
end
let(:url) { 'https://ollama.ai' }
it 'does not raise error' do
expect { Setting.set(setting_name, { 'url' => 'https://ollama.ai' }) }.not_to raise_error
expect { Setting.set(setting_name, config) }
.not_to raise_error
end
end
end
context 'when provider is ZammadAI' do
let(:config) { { provider: 'zammad_ai', token: } }
before do
allow(UserAgent).to receive(:get) do |_, _, options|
success = options[:bearer_token] == 'valid'
UserAgent::Result.new(
error: '',
success:,
code: success ? 200 : 400,
)
end
end
context 'with missing token' do
let(:token) { nil }
it 'raises error' do
expect { Setting.set(setting_name, config) }
.to raise_error(ActiveRecord::RecordInvalid)
end
end
context 'with valid token' do
let(:token) { 'valid' }
it 'does not raise error' do
expect { Setting.set(setting_name, config) }
.not_to raise_error
end
end
context 'with invalid token' do
let(:token) { 'invalid_token' }
it 'raises error' do
expect { Setting.set(setting_name, config) }
.to raise_error(ActiveRecord::RecordInvalid)
end
end
context 'when in SaaS or developer mode' do
before do
Setting.set('system_online_service', true)
Setting.set('developer_mode', true)
end
around do |example|
old_env = ENV['ZAMMAD_AI_TOKEN']
ENV['ZAMMAD_AI_TOKEN'] = env_value
example.run
ENV['ZAMMAD_AI_TOKEN'] = old_env
end
context 'when ENV variable is present' do
let(:env_value) { 'valid' }
context 'with missing token' do
let(:token) { nil }
it 'does not raise error' do
expect { Setting.set(setting_name, config) }
.not_to raise_error
end
end
context 'with valid token' do
let(:token) { 'valid' }
it 'does not raise error' do
expect { Setting.set(setting_name, config) }
.not_to raise_error
end
end
context 'with invalid token' do
let(:token) { 'invalid_token' }
it 'raises error' do
expect { Setting.set(setting_name, config) }
.to raise_error(ActiveRecord::RecordInvalid)
end
end
end
context 'when ENV variable is missing' do
let(:env_value) { nil }
context 'with missing token' do
let(:token) { nil }
it 'does not raise error' do
expect { Setting.set(setting_name, config) }
.to raise_error(ActiveRecord::RecordInvalid)
end
end
context 'with valid token' do
let(:token) { 'valid' }
it 'does not raise error' do
expect { Setting.set(setting_name, config) }
.not_to raise_error
end
end
context 'with invalid token' do
let(:token) { 'invalid_token' }
it 'raises error' do
expect { Setting.set(setting_name, config) }
.to raise_error(ActiveRecord::RecordInvalid)
end
end
end
end
end
context 'when provider is not ollama' do
let(:config) { { provider: 'open_ai', token: } }
before do
Setting.set('ai_provider', 'open_ai')
allow(AI::Provider::OpenAI).to receive(:ping!).with(hash_including(token:)) do |hash|
next if hash[:token] == 'valid'
raise AI::Provider::ResponseError, 'API server not accessible'
end
end
context 'with missing token' do
let(:token) { nil }
it 'raises error' do
expect { Setting.set(setting_name, { 'token' => nil }) }.to raise_error(ActiveRecord::RecordInvalid)
expect { Setting.set(setting_name, config) }
.to raise_error(ActiveRecord::RecordInvalid)
end
end
context 'with valid token' do
let(:token) { 'valid' }
it 'does not raise error' do
expect { Setting.set(setting_name, { 'token' => ENV['OPEN_AI_TOKEN'] }) }.not_to raise_error
expect { Setting.set(setting_name, config) }
.not_to raise_error
end
end
context 'with invalid token' do
let(:token) { 'invalid_token' }
it 'raises error' do
expect { Setting.set(setting_name, { 'token' => 'invalid_token' }) }.to raise_error(ActiveRecord::RecordInvalid)
expect { Setting.set(setting_name, config) }
.to raise_error(ActiveRecord::RecordInvalid)
end
end
end

View file

@ -1,26 +0,0 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Setting::Validation::AIProvider do
let(:setting_name) { 'ai_provider' }
context 'with blank settings' do
it 'does not raise error' do
expect { Setting.set(setting_name, '') }.not_to raise_error
end
end
context 'with unsupported provider' do
it 'raises error' do
expect { Setting.set(setting_name, 'unsupported') }.to raise_error(ActiveRecord::RecordInvalid)
end
end
context 'with supported provider' do
it 'does not raise error' do
expect { Setting.set(setting_name, 'open_ai') }.not_to raise_error
end
end
end

View file

@ -18,7 +18,7 @@ RSpec.describe 'AI Assistance API endpoint', authenticated_as: :user, type: :req
before do
allow(AI::Provider::ZammadAI).to receive(:ping!).and_return(true)
Setting.set('ai_provider', 'zammad_ai')
setup_ai_provider
Setting.set('ai_assistance_text_tools', true)
end

View file

@ -12,7 +12,7 @@ RSpec.describe 'Ticket Summarize API endpoints', authenticated_as: :user, perfor
before do
allow(AI::Provider::ZammadAI).to receive(:ping!).and_return(true)
Setting.set('ai_provider', 'zammad_ai')
setup_ai_provider
Setting.set('ai_assistance_ticket_summary', ai_assistance_ticket_summary)
end

View file

@ -147,7 +147,8 @@ RSpec.describe Service::AI::Agent::Run::Context, type: :service do
before do
articles
Setting.set('ai_provider', 'open_ai')
setup_ai_provider('open_ai')
# Mock the AI provider to avoid real API calls
allow(AI::Provider::OpenAI).to receive(:new).and_return(mock_provider)

View file

@ -39,7 +39,11 @@ RSpec.describe Service::AI::Agent::Run do
let(:ai_provider) { 'open_ai' }
before do
Setting.set('ai_provider', ai_provider)
if ai_provider.present?
setup_ai_provider(ai_provider, token: ENV['OPEN_AI_TOKEN'])
else
unset_ai_provider
end
end
describe '#execute' do

View file

@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe Service::AI::VectorDB::Available do
before do
Setting.set('ai_provider', 'open_ai')
setup_ai_provider('open_ai')
end
it 'Checks if vector database is available' do

View file

@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe Service::AI::VectorDB::CreateTable do
before do
Setting.set('ai_provider', 'open_ai')
setup_ai_provider('open_ai')
end
it 'Create vector database table' do

View file

@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe Service::AI::VectorDB::DropTable do
before do
Setting.set('ai_provider', 'open_ai')
setup_ai_provider('open_ai')
end
it 'Drop vector database table' do

View file

@ -6,7 +6,7 @@ RSpec.describe Service::AI::VectorDB::Item::Create do
let(:object) { create(:ticket) }
before do
Setting.set('ai_provider', 'open_ai')
setup_ai_provider('open_ai')
end
it 'creates a vector database item' do

View file

@ -6,7 +6,8 @@ RSpec.describe Service::AI::VectorDB::Item::Destroy do
let(:object) { create(:ticket) }
before do
Setting.set('ai_provider', 'open_ai')
setup_ai_provider('open_ai')
end
it 'destroys a vector database item' do

View file

@ -6,7 +6,8 @@ RSpec.describe Service::AI::VectorDB::Item::Upsert do
let(:object) { create(:ticket) }
before do
Setting.set('ai_provider', 'open_ai')
setup_ai_provider('open_ai')
end
it 'Upserts a vector database item' do

View file

@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe Service::AI::VectorDB::Rebuild do
before do
Setting.set('ai_provider', 'open_ai')
setup_ai_provider('open_ai')
end
it 'Rebuild vector database table', :aggregate_failures do

View file

@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe Service::AI::VectorDB::Reload do
before do
Setting.set('ai_provider', 'open_ai')
setup_ai_provider('open_ai')
end
it 'Rebuild vector database table' do

View file

@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe Service::AI::VectorDB::SimilaritySearch do
before do
Setting.set('ai_provider', 'open_ai')
setup_ai_provider('open_ai')
end
it 'Rebuild vector database table' do

View file

@ -10,7 +10,7 @@ RSpec.describe Service::AIAssistance::TextTools do
context 'when text tool service is used' do
before do
Setting.set('ai_provider', 'open_ai')
setup_ai_provider('open_ai')
Setting.set('ai_assistance_text_tools', true)
allow_any_instance_of(AI::Service::TextTool)

View file

@ -0,0 +1,29 @@
# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
module AIProviderHelper
# @param provider [String] (e.g., 'zammad_ai'[DEFAULT], 'open_ai', 'azure', 'anthropic', 'mistral')
# @param token [String] API token for the AI provider
# @param additional_config [Hash] Additional configuration options including :token
def setup_ai_provider(provider = 'zammad_ai', token: nil, **additional_config)
Setting.set('ai_provider', true)
config = {
provider:,
token:,
}
.merge(additional_config)
.compact_blank!
# Disable validation to avoid ping!
Setting.set('ai_provider_config', config, validate: false)
end
def unset_ai_provider
Setting.set('ai_provider', false)
Setting.set('ai_provider_config', {})
end
end
RSpec.configure do |config|
config.include AIProviderHelper
end

View file

@ -3,11 +3,18 @@
require 'rails_helper'
RSpec.describe 'AI > Provider', authenticated_as: :admin, type: :system do
let(:admin) { create(:admin) }
let(:admin) { create(:admin) }
let(:self_hosted) { false }
let(:initial_ai_provider_config) { {} }
before do
setting = Setting.find_by(name: 'ai_provider_config')
setting.update!(preferences: {})
Setting.set('ai_provider_config', initial_ai_provider_config, validate: false)
Setting.set('ai_provider', initial_ai_provider_config.present?, validate: false)
if self_hosted
Setting.set('system_online_service', false)
Setting.set('developer_mode', false)
end
result = UserAgent::Result.new(
success: true,
@ -33,8 +40,8 @@ RSpec.describe 'AI > Provider', authenticated_as: :admin, type: :system do
await_empty_ajax_queue
# Verify settings were saved
expect(Setting.get('ai_provider')).to eq('open_ai')
expect(Setting.get('ai_provider_config')).to eq({ 'token' => '1234111' })
expect(Setting.get('ai_provider')).to be(true)
expect(Setting.get('ai_provider_config')).to include({ provider: 'open_ai', token: '1234111' })
end
end
@ -56,7 +63,28 @@ RSpec.describe 'AI > Provider', authenticated_as: :admin, type: :system do
.and(have_field('Model', placeholder: AI::Provider::Anthropic::DEFAULT_OPTIONS[:model]))
find('select[name=provider]').select('Azure AI')
expect(page).to have_field('URL').and(have_field('Token')).and(have_no_field('Model'))
expect(page)
.to have_field('URL')
.and(have_field('Token'))
.and(have_no_field('Model'))
find('select[name=provider]').select('Zammad AI')
expect(page)
.to have_no_field('Token')
.and(have_no_field('Model'))
.and(have_no_field('URL'))
end
end
context 'when self-hosted' do
let(:self_hosted) { true }
it 'shows Zammad AI Token field' do
find('select[name=provider]').select('Zammad AI')
expect(page)
.to have_field('Token')
.and(have_no_field('Model'))
.and(have_no_field('URL'))
end
end
@ -86,8 +114,24 @@ RSpec.describe 'AI > Provider', authenticated_as: :admin, type: :system do
expect(page).to have_text('Update successful.')
expect(Setting.get('ai_provider')).to eq('open_ai')
expect(Setting.get('ai_provider_config')).to include(token: token, model: model)
expect(Setting.get('ai_provider')).to be(true)
expect(Setting.get('ai_provider_config')).to include(provider: 'open_ai', token:, model:)
end
context 'with initial configuration' do
let(:initial_ai_provider_config) do
{ provider: 'open_ai', token: '123' }
end
it 'saves clear configuration' do
find('select[name=provider]').select('-')
click '.js-provider-submit'
expect(page).to have_text('Update successful.')
expect(Setting.get('ai_provider')).to be(false)
expect(Setting.get('ai_provider_config')).to be_blank
end
end
it 'shows feedback & logs tab with downloads and entries' do

View file

@ -16,7 +16,7 @@ RSpec.describe 'Manage > AI > Text Tool', type: :system do
before do
allow(AI::Provider::ZammadAI).to receive(:ping!).and_return(true)
Setting.set('ai_provider', 'zammad_ai')
setup_ai_provider
Setting.set('ai_assistance_text_tools', true)
end
@ -33,7 +33,7 @@ RSpec.describe 'Manage > AI > Text Tool', type: :system do
context 'without provider configured' do
before do
Setting.set('ai_provider', '')
unset_ai_provider
Setting.set('ai_assistance_text_tools', true)
end

View file

@ -38,7 +38,7 @@ RSpec.describe 'Manage > AI > Ticket Summary', type: :system do
context 'without provider configured' do
before do
Setting.set('ai_provider', '')
unset_ai_provider
visit '/#ai/ticket_summary'
page.refresh
end

View file

@ -5,10 +5,7 @@ require 'system/examples/pagination_examples'
RSpec.describe 'AI > AI Agents > Types > Ticket Categorizer', db_strategy: :reset, type: :system do
before do
Setting.set('ai_provider', 'open_ai')
Setting.set('ai_provider_config', {
token: ENV['OPEN_AI_TOKEN'],
})
setup_ai_provider('open_ai', token: ENV['OPEN_AI_TOKEN'])
create(:object_manager_attribute_select, name: 'example_category', display: 'Example Category')
create(:object_manager_attribute_select, name: 'example_industry', display: 'Example Industry')

View file

@ -18,10 +18,7 @@ RSpec.describe 'AI > AI Agents', type: :system do
context 'when AI provider is configured', required_envs: %w[OPEN_AI_TOKEN] do
before do
Setting.set('ai_provider', 'open_ai')
Setting.set('ai_provider_config', {
token: ENV['OPEN_AI_TOKEN'],
})
setup_ai_provider('open_ai', token: ENV['OPEN_AI_TOKEN'])
end
context 'with existing AI agent(s)' do

View file

@ -29,7 +29,7 @@ RSpec.describe 'Ticket create > Inline Image Replacement for AI Text Tools', aut
allow(AI::Provider::ZammadAI).to receive(:ping!).and_return(true)
Setting.set('ai_provider', 'zammad_ai')
setup_ai_provider
Setting.set('ai_assistance_text_tools', true)
Setting.set('ui_richtext_bubble_menu', true)

View file

@ -16,7 +16,12 @@ RSpec.describe 'Richtext Bubble Menu', authenticated_as: :authenticate, type: :s
allow(AI::Provider::ZammadAI).to receive(:ping!).and_return(true)
Setting.set('ai_provider', ai_provider)
if ai_provider
setup_ai_provider(ai_provider)
else
unset_ai_provider
end
Setting.set('ai_assistance_text_tools', ai_assistance_text_tools)
Setting.set('ui_richtext_bubble_menu', ui_richtext_bubble_menu)
@ -58,7 +63,7 @@ RSpec.describe 'Richtext Bubble Menu', authenticated_as: :authenticate, type: :s
shared_examples 'not showing text tools button' do
context 'when ai provider is not set' do
let(:ai_provider) { '' }
let(:ai_provider) { false }
it 'does not show text tools button' do
set_editor_field_value('body', input)

View file

@ -48,7 +48,7 @@ RSpec.describe 'Ticket Summary', authenticated_as: :authenticate, type: :system
def authenticate
allow(AI::Provider::ZammadAI).to receive(:ping!).and_return(true)
Setting.set('ai_provider', ai_provider)
setup_ai_provider(ai_provider)
Setting.set('ai_assistance_ticket_summary', ai_assistance_ticket_summary)
Setting.set('ai_assistance_ticket_summary_config', {
open_questions: true,

View file

@ -23,7 +23,7 @@ RSpec.describe 'Ticket Zoom > Text Tools Analytics', authenticated_as: :authenti
allow(AI::Provider::ZammadAI).to receive(:ping!).and_return(true)
Setting.set('ai_provider', 'zammad_ai')
setup_ai_provider
Setting.set('ai_assistance_text_tools', true)
Setting.set('ui_richtext_bubble_menu', true)

View file

@ -1,444 +0,0 @@
---
http_interactions:
- request:
method: get
uri: https://api.openai.com/v1/models
body:
encoding: US-ASCII
string: ''
headers:
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
- "*/*"
User-Agent:
- Zammad User Agent
Host:
- api.openai.com
Authorization:
- Bearer <OPEN_AI_TOKEN>
response:
status:
code: 200
message: OK
headers:
Date:
- Wed, 02 Apr 2025 20:20:36 GMT
Content-Type:
- application/json
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Openai-Version:
- '2020-10-01'
X-Request-Id:
- a9ce9bcef3633fd57d2f3c07c3b0c7a4
Openai-Processing-Ms:
- '493'
Strict-Transport-Security:
- max-age=31536000; includeSubDomains; preload
Cf-Cache-Status:
- DYNAMIC
Set-Cookie:
- __cf_bm=qL5GgNpNw1SDaT3I3FnOtRzDPj1GOROHoJNBC9SD_rk-1743625236-1.0.1.1-QKYXkbOrvBUCb9RIDlYJFlLT5s_hxokAZaBe6H1HPrfZ50RrtHmVLJ7Jm9qbI1r2jcpIv4j4aqe.xOLFWVcmv18pJig4xQLoOCPil.VqiwE;
path=/; expires=Wed, 02-Apr-25 20:50:36 GMT; domain=.api.openai.com; HttpOnly;
Secure; SameSite=None
- _cfuvid=DMCyLnwAlq95eaPhhekjLBvMRF4BrVrJ_c1_LXOiC.k-1743625236294-0.0.1.1-604800000;
path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None
X-Content-Type-Options:
- nosniff
Server:
- cloudflare
Cf-Ray:
- 92a3071b084ad2de-FRA
Alt-Svc:
- h3=":443"; ma=86400
body:
encoding: ASCII-8BIT
string: |-
{
"object": "list",
"data": [
{
"id": "gpt-4o-realtime-preview-2024-12-17",
"object": "model",
"created": 1733945430,
"owned_by": "system"
},
{
"id": "gpt-4o-audio-preview-2024-12-17",
"object": "model",
"created": 1734034239,
"owned_by": "system"
},
{
"id": "dall-e-3",
"object": "model",
"created": 1698785189,
"owned_by": "system"
},
{
"id": "dall-e-2",
"object": "model",
"created": 1698798177,
"owned_by": "system"
},
{
"id": "gpt-4o-audio-preview-2024-10-01",
"object": "model",
"created": 1727389042,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-realtime-preview-2024-12-17",
"object": "model",
"created": 1734112601,
"owned_by": "system"
},
{
"id": "o3-mini",
"object": "model",
"created": 1737146383,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-realtime-preview",
"object": "model",
"created": 1734387380,
"owned_by": "system"
},
{
"id": "gpt-4o-realtime-preview-2024-10-01",
"object": "model",
"created": 1727131766,
"owned_by": "system"
},
{
"id": "gpt-4o-transcribe",
"object": "model",
"created": 1742068463,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-transcribe",
"object": "model",
"created": 1742068596,
"owned_by": "system"
},
{
"id": "gpt-4o-realtime-preview",
"object": "model",
"created": 1727659998,
"owned_by": "system"
},
{
"id": "babbage-002",
"object": "model",
"created": 1692634615,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-tts",
"object": "model",
"created": 1742403959,
"owned_by": "system"
},
{
"id": "o3-mini-2025-01-31",
"object": "model",
"created": 1738010200,
"owned_by": "system"
},
{
"id": "tts-1-hd-1106",
"object": "model",
"created": 1699053533,
"owned_by": "system"
},
{
"id": "text-embedding-3-large",
"object": "model",
"created": 1705953180,
"owned_by": "system"
},
{
"id": "gpt-4",
"object": "model",
"created": 1687882411,
"owned_by": "openai"
},
{
"id": "text-embedding-ada-002",
"object": "model",
"created": 1671217299,
"owned_by": "openai-internal"
},
{
"id": "omni-moderation-latest",
"object": "model",
"created": 1731689265,
"owned_by": "system"
},
{
"id": "tts-1-hd",
"object": "model",
"created": 1699046015,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-audio-preview",
"object": "model",
"created": 1734387424,
"owned_by": "system"
},
{
"id": "gpt-4o-audio-preview",
"object": "model",
"created": 1727460443,
"owned_by": "system"
},
{
"id": "o1-preview-2024-09-12",
"object": "model",
"created": 1725648865,
"owned_by": "system"
},
{
"id": "gpt-3.5-turbo-instruct-0914",
"object": "model",
"created": 1694122472,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-search-preview",
"object": "model",
"created": 1741391161,
"owned_by": "system"
},
{
"id": "tts-1-1106",
"object": "model",
"created": 1699053241,
"owned_by": "system"
},
{
"id": "davinci-002",
"object": "model",
"created": 1692634301,
"owned_by": "system"
},
{
"id": "gpt-3.5-turbo-1106",
"object": "model",
"created": 1698959748,
"owned_by": "system"
},
{
"id": "gpt-4-turbo",
"object": "model",
"created": 1712361441,
"owned_by": "system"
},
{
"id": "gpt-4-0125-preview",
"object": "model",
"created": 1706037612,
"owned_by": "system"
},
{
"id": "gpt-3.5-turbo-instruct",
"object": "model",
"created": 1692901427,
"owned_by": "system"
},
{
"id": "gpt-3.5-turbo",
"object": "model",
"created": 1677610602,
"owned_by": "openai"
},
{
"id": "gpt-4-turbo-preview",
"object": "model",
"created": 1706037777,
"owned_by": "system"
},
{
"id": "chatgpt-4o-latest",
"object": "model",
"created": 1723515131,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-search-preview-2025-03-11",
"object": "model",
"created": 1741390858,
"owned_by": "system"
},
{
"id": "gpt-4o-2024-11-20",
"object": "model",
"created": 1739331543,
"owned_by": "system"
},
{
"id": "whisper-1",
"object": "model",
"created": 1677532384,
"owned_by": "openai-internal"
},
{
"id": "gpt-3.5-turbo-0125",
"object": "model",
"created": 1706048358,
"owned_by": "system"
},
{
"id": "gpt-4o-2024-05-13",
"object": "model",
"created": 1715368132,
"owned_by": "system"
},
{
"id": "gpt-3.5-turbo-16k",
"object": "model",
"created": 1683758102,
"owned_by": "openai-internal"
},
{
"id": "gpt-4-turbo-2024-04-09",
"object": "model",
"created": 1712601677,
"owned_by": "system"
},
{
"id": "gpt-4-1106-preview",
"object": "model",
"created": 1698957206,
"owned_by": "system"
},
{
"id": "o1-preview",
"object": "model",
"created": 1725648897,
"owned_by": "system"
},
{
"id": "gpt-4-0613",
"object": "model",
"created": 1686588896,
"owned_by": "openai"
},
{
"id": "gpt-4o-search-preview",
"object": "model",
"created": 1741388720,
"owned_by": "system"
},
{
"id": "gpt-4.5-preview",
"object": "model",
"created": 1740623059,
"owned_by": "system"
},
{
"id": "gpt-4.5-preview-2025-02-27",
"object": "model",
"created": 1740623304,
"owned_by": "system"
},
{
"id": "gpt-4o-search-preview-2025-03-11",
"object": "model",
"created": 1741388170,
"owned_by": "system"
},
{
"id": "tts-1",
"object": "model",
"created": 1681940951,
"owned_by": "openai-internal"
},
{
"id": "omni-moderation-2024-09-26",
"object": "model",
"created": 1732734466,
"owned_by": "system"
},
{
"id": "o1-2024-12-17",
"object": "model",
"created": 1734326976,
"owned_by": "system"
},
{
"id": "o1",
"object": "model",
"created": 1734375816,
"owned_by": "system"
},
{
"id": "o1-pro",
"object": "model",
"created": 1742251791,
"owned_by": "system"
},
{
"id": "text-embedding-3-small",
"object": "model",
"created": 1705948997,
"owned_by": "system"
},
{
"id": "o1-pro-2025-03-19",
"object": "model",
"created": 1742251504,
"owned_by": "system"
},
{
"id": "gpt-4o",
"object": "model",
"created": 1715367049,
"owned_by": "system"
},
{
"id": "gpt-4o-mini",
"object": "model",
"created": 1721172741,
"owned_by": "system"
},
{
"id": "gpt-4o-2024-08-06",
"object": "model",
"created": 1722814719,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-2024-07-18",
"object": "model",
"created": 1721172717,
"owned_by": "system"
},
{
"id": "o1-mini",
"object": "model",
"created": 1725649008,
"owned_by": "system"
},
{
"id": "gpt-4o-mini-audio-preview-2024-12-17",
"object": "model",
"created": 1734115920,
"owned_by": "system"
},
{
"id": "o1-mini-2024-09-12",
"object": "model",
"created": 1725648979,
"owned_by": "system"
}
]
}
recorded_at: Wed, 02 Apr 2025 20:20:36 GMT
recorded_with: VCR 6.3.1