Fixes #6060 - Missing temperature support by some models for some providers.
Co-authored-by: Dominik Klein <dk@zammad.com> Co-authored-by: Dusan Vuckovic <dv@zammad.com>
This commit is contained in:
@@ -85,14 +85,14 @@ class AiProviderSettings extends App.Controller
|
||||
release: =>
|
||||
App.Setting.unsubscribe(@subscribeId)
|
||||
|
||||
render: =>
|
||||
return if _.isEqual(@aiProviderConfigAtRender, App.Setting.get('ai_provider_config'))
|
||||
@aiProviderConfigAtRender = App.Setting.get('ai_provider_config')
|
||||
render: (_, localOrServer) =>
|
||||
return if localOrServer isnt 'refresh'
|
||||
|
||||
@html App.view('ai/provider')(
|
||||
description: @description,
|
||||
)
|
||||
@form = new ProviderForm()
|
||||
|
||||
new ProviderForm()
|
||||
|
||||
class AiProviderFeedbackAndLogs extends App.Controller
|
||||
@requiredPermission: 'admin.ai_provider'
|
||||
@@ -159,11 +159,7 @@ class AiProviderFeedbackAndLogs extends App.Controller
|
||||
@httpLog = null
|
||||
super
|
||||
|
||||
|
||||
class ProviderForm extends App.Controller
|
||||
events:
|
||||
'.js-provider-submit': 'update'
|
||||
|
||||
constructor: (content) ->
|
||||
super
|
||||
|
||||
@@ -172,7 +168,6 @@ class ProviderForm extends App.Controller
|
||||
|
||||
@render(content)
|
||||
|
||||
|
||||
getSortedProviderOptions: ->
|
||||
Object
|
||||
.entries(@providers)
|
||||
@@ -191,7 +186,7 @@ class ProviderForm extends App.Controller
|
||||
type: 'password'
|
||||
single: true
|
||||
null: not _.contains(provider.required, 'token')
|
||||
autocomplete: 'off'
|
||||
autocomplete: 'new-password'
|
||||
value: params.token
|
||||
}
|
||||
model: {
|
||||
@@ -315,6 +310,7 @@ class ProviderForm extends App.Controller
|
||||
)
|
||||
|
||||
$('.js-provider-submit').off('click.provider').on('click.provider', @update)
|
||||
|
||||
$('select[name=provider]').off('change.provider').on('change.provider', (e) =>
|
||||
@render($(e.target).val())
|
||||
)
|
||||
@@ -335,6 +331,8 @@ class ProviderForm extends App.Controller
|
||||
@validateAndSave(params)
|
||||
|
||||
validateAndSave: (params) ->
|
||||
App.ControllerForm.disable(@providerSettingsForm.form)
|
||||
|
||||
has_provider = not _.isEmpty(params.provider)
|
||||
|
||||
if not has_provider
|
||||
@@ -350,25 +348,32 @@ class ProviderForm extends App.Controller
|
||||
if has_provider && !params.hasOwnProperty('token') && savedProviderConfig.provider == params.provider && savedProviderConfig.token
|
||||
params.token = savedProviderConfig.token
|
||||
|
||||
App.Setting.set('ai_provider_config', params, done: =>
|
||||
# If the provider configuration is being updated, or the provider is already disabled,
|
||||
# do not touch the provider switch.
|
||||
if has_provider or not App.Config.get('ai_provider')
|
||||
App.Event.trigger 'notify', {
|
||||
type: 'success'
|
||||
msg: __('Update successful.')
|
||||
timeout: 2000
|
||||
}
|
||||
App.Setting.set(
|
||||
'ai_provider_config',
|
||||
params,
|
||||
done: =>
|
||||
App.ControllerForm.enable(@providerSettingsForm.form)
|
||||
|
||||
return
|
||||
# If the provider configuration is being updated, or the provider is already disabled,
|
||||
# do not touch the provider switch.
|
||||
if has_provider or not App.Config.get('ai_provider')
|
||||
App.Event.trigger 'notify', {
|
||||
type: 'success'
|
||||
msg: __('Update successful.')
|
||||
timeout: 2000
|
||||
}
|
||||
|
||||
# Turn off the provider switch when the provider configuration is emptied.
|
||||
App.Setting.set('ai_provider', false, done: =>
|
||||
@notify(
|
||||
type: 'success'
|
||||
msg: __('AI provider disabled successfully.')
|
||||
return
|
||||
|
||||
# Turn off the provider switch when the provider configuration is emptied.
|
||||
App.Setting.set('ai_provider', false, done: =>
|
||||
@notify(
|
||||
type: 'success'
|
||||
msg: __('AI provider disabled successfully.')
|
||||
)
|
||||
)
|
||||
)
|
||||
fail: =>
|
||||
App.ControllerForm.enable(@providerSettingsForm.form)
|
||||
)
|
||||
|
||||
App.Config.set('Provider', { prio: 1000, name: __('Provider'), parent: '#ai', target: '#ai/provider', controller: ChannelAiProvider, permission: ['admin.ai_provider'] }, 'NavBarAdmin')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
App.Config.set('azure', {
|
||||
key: 'azure'
|
||||
label: __('Azure AI')
|
||||
label: __('Azure AI (legacy deployment-based endpoints)')
|
||||
prio: 5000
|
||||
fields: ['token', 'url_completions', 'ocr_active', 'url_ocr'] # TODO: Add url_embeddings when needed.
|
||||
required: ['token', 'url_completions']
|
||||
|
||||
@@ -77,10 +77,17 @@ class Setting::Validation::AIProviderConfig < Setting::Validation::Base
|
||||
end
|
||||
|
||||
def accessible
|
||||
AI::Provider
|
||||
.by_name(provider)
|
||||
.ping!(value)
|
||||
provider_class = AI::Provider.by_name(provider)
|
||||
|
||||
provider_class.ping!(value)
|
||||
check_temperature_support!(provider_class)
|
||||
rescue AIProviderConfigError
|
||||
raise
|
||||
rescue => e
|
||||
raise AIProviderConfigError, __("AI provider is not accessible: #{e.message}")
|
||||
end
|
||||
|
||||
def check_temperature_support!(provider_class)
|
||||
value['model_temperature_support'] = provider_class.check_temperature_support!(value)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -835,7 +835,7 @@ msgstr ""
|
||||
msgid "API server error: %s"
|
||||
msgstr ""
|
||||
|
||||
#: lib/ai/provider/azure.rb:128
|
||||
#: lib/ai/provider/azure.rb:168
|
||||
msgid "API server not accessible"
|
||||
msgstr ""
|
||||
|
||||
@@ -2449,7 +2449,7 @@ msgid "Average: %s%"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/lib/app_post/ai_provider/azure_ai.coffee:3
|
||||
msgid "Azure AI"
|
||||
msgid "Azure AI (legacy deployment-based endpoints)"
|
||||
msgstr ""
|
||||
|
||||
#: db/seeds/settings.rb:3493
|
||||
@@ -9560,11 +9560,11 @@ msgstr ""
|
||||
msgid "Leave BETA UI feedback program?"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:263
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:258
|
||||
msgid "Leave empty to use URL (Completions)"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:253
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:248
|
||||
msgid "Leave empty to use the base model"
|
||||
msgstr ""
|
||||
|
||||
@@ -10663,7 +10663,7 @@ msgstr ""
|
||||
msgid "Mode"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:199
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:194
|
||||
msgid "Model"
|
||||
msgstr ""
|
||||
|
||||
@@ -11819,7 +11819,7 @@ msgstr ""
|
||||
msgid "OAuth2 token is expired!"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:246
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:241
|
||||
msgid "OCR Model"
|
||||
msgstr ""
|
||||
|
||||
@@ -13416,7 +13416,7 @@ msgstr ""
|
||||
msgid "Recipient"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:237
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:232
|
||||
msgid "Recognize image text (OCR)"
|
||||
msgstr ""
|
||||
|
||||
@@ -17200,7 +17200,7 @@ msgstr ""
|
||||
msgid "The required value 'group_id' is missing."
|
||||
msgstr ""
|
||||
|
||||
#: lib/ai/provider.rb:65
|
||||
#: lib/ai/provider.rb:69
|
||||
msgid "The response could not be processed."
|
||||
msgstr ""
|
||||
|
||||
@@ -18721,7 +18721,7 @@ msgstr ""
|
||||
msgid "Toggle the overlay"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:189
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:184
|
||||
#: 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
|
||||
@@ -19071,7 +19071,7 @@ msgstr ""
|
||||
msgid "UID Field"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:209
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:204
|
||||
#: 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
|
||||
@@ -19088,15 +19088,15 @@ msgstr ""
|
||||
msgid "URL (AJAX endpoint)"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:219
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:214
|
||||
msgid "URL (Completions)"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:228
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:223
|
||||
msgid "URL (Embeddings)"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:257
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:252
|
||||
msgid "URL (OCR)"
|
||||
msgstr ""
|
||||
|
||||
@@ -19282,7 +19282,7 @@ msgstr ""
|
||||
msgid "Update shared draft"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:359
|
||||
#: app/assets/javascripts/app/controllers/_ai/provider.coffee:362
|
||||
#: app/assets/javascripts/app/controllers/_profile/calendar_subscriptions.coffee:81
|
||||
#: app/assets/javascripts/app/controllers/_profile/language.coffee:64
|
||||
#: app/assets/javascripts/app/controllers/_profile/linked_accounts.coffee:48
|
||||
|
||||
@@ -43,6 +43,10 @@ class AI::Provider
|
||||
def ping!(_config)
|
||||
raise 'not implemented'
|
||||
end
|
||||
|
||||
def check_temperature_support!(_config)
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def ask(prompt_system:, prompt_user:, prompt_image: nil)
|
||||
@@ -79,6 +83,10 @@ class AI::Provider
|
||||
|
||||
private
|
||||
|
||||
def model_supports_temperature?
|
||||
config[:model_temperature_support] != false
|
||||
end
|
||||
|
||||
def transform_json_response(result)
|
||||
return result if result.blank?
|
||||
|
||||
@@ -117,4 +125,5 @@ class AI::Provider
|
||||
class RequestError < StandardError; end
|
||||
class ResponseError < StandardError; end
|
||||
class OutputFormatError < ResponseError; end
|
||||
class CheckTemperatureSupportError < ResponseError; end
|
||||
end
|
||||
|
||||
@@ -34,21 +34,24 @@ class AI::Provider::Anthropic < AI::Provider
|
||||
prompt_user
|
||||
end
|
||||
|
||||
request_body = {
|
||||
max_tokens: options[:max_tokens],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content:,
|
||||
},
|
||||
],
|
||||
model: model_for(prompt_image:),
|
||||
stream: false,
|
||||
system: prompt_system,
|
||||
}
|
||||
|
||||
request_body[:temperature] = options[:temperature] if model_supports_temperature?
|
||||
|
||||
response = UserAgent.post(
|
||||
"#{ANTHROPIC_API_BASE_URL}/messages",
|
||||
{
|
||||
max_tokens: options[:max_tokens],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content:,
|
||||
},
|
||||
],
|
||||
model: model_for(prompt_image:),
|
||||
stream: false,
|
||||
system: prompt_system,
|
||||
temperature: options[:temperature],
|
||||
},
|
||||
request_body,
|
||||
{
|
||||
open_timeout: 4,
|
||||
read_timeout: 60,
|
||||
|
||||
@@ -4,17 +4,20 @@ class AI::Provider::Azure < AI::Provider
|
||||
include AI::Provider::Concerns::HandlesOpenAIMessages
|
||||
|
||||
def chat(prompt_system:, prompt_user:, prompt_image:)
|
||||
request_body = {
|
||||
messages: messages_for(prompt_system:, prompt_user:, prompt_image:),
|
||||
response_format: {
|
||||
type: options[:json_response] ? 'json_object' : 'text'
|
||||
},
|
||||
stream: false,
|
||||
store: false,
|
||||
}
|
||||
|
||||
request_body[:temperature] = options[:temperature] if model_supports_temperature?
|
||||
|
||||
response = UserAgent.post(
|
||||
chat_url_for(prompt_image:),
|
||||
{
|
||||
messages: messages_for(prompt_system:, prompt_user:, prompt_image:),
|
||||
temperature: options[:temperature],
|
||||
response_format: {
|
||||
type: options[:json_response] ? 'json_object' : 'text'
|
||||
},
|
||||
stream: false,
|
||||
store: false,
|
||||
},
|
||||
request_body,
|
||||
{
|
||||
open_timeout: 4,
|
||||
read_timeout: 60,
|
||||
@@ -67,6 +70,44 @@ class AI::Provider::Azure < AI::Provider
|
||||
nil
|
||||
end
|
||||
|
||||
def self.check_temperature_support!(config)
|
||||
response = UserAgent.post(
|
||||
config[:url_completions],
|
||||
{
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
temperature: 0.1,
|
||||
stream: false,
|
||||
store: false,
|
||||
},
|
||||
{
|
||||
open_timeout: 4,
|
||||
read_timeout: 60,
|
||||
verify_ssl: true,
|
||||
bearer_token: config[:token],
|
||||
total_timeout: 60,
|
||||
json: true,
|
||||
log: {
|
||||
facility: 'AI::Provider',
|
||||
log_only_on_error: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return true if response.success?
|
||||
|
||||
data = JSON.parse(response.body)
|
||||
data = data.pop if data.is_a?(Array) # Handle case when response is an array of errors
|
||||
message = data.dig('error', 'message')
|
||||
type = data.dig('error', 'type')
|
||||
param = data.dig('error', 'param')
|
||||
code = data.dig('error', 'code')
|
||||
return false if type == 'invalid_request_error' && param == 'temperature' && code == 'unsupported_value'
|
||||
|
||||
raise message
|
||||
rescue => e
|
||||
raise CheckTemperatureSupportError, e.message
|
||||
end
|
||||
|
||||
def self.ping_chat!(config)
|
||||
response = UserAgent.post(
|
||||
config[:url_completions],
|
||||
@@ -81,7 +122,6 @@ class AI::Provider::Azure < AI::Provider
|
||||
content: 'Ping pong in JSON', # rubocop:disable Zammad/DetectTranslatableString
|
||||
},
|
||||
],
|
||||
temperature: 0,
|
||||
response_format: {
|
||||
type: 'json_object'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class AI::Provider
|
||||
module Concerns::HasModelsWithoutTemperatureFallback
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
private
|
||||
|
||||
def model_supports_temperature?
|
||||
# Use the dynamically detected flag if it has been set.
|
||||
return config[:model_temperature_support] if config[:model_temperature_support].present?
|
||||
|
||||
# Fall back to the hardcoded list for backward compatibility.
|
||||
current_model = options[:model]
|
||||
options[:models_without_temperature].none? { |model_pattern| current_model.start_with?(model_pattern) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,9 +3,11 @@
|
||||
class AI::Provider::CustomOpenAI < AI::Provider
|
||||
include AI::Provider::Concerns::HandlesOpenAIMessages
|
||||
include AI::Provider::Concerns::HasConfigurableModel
|
||||
include AI::Provider::Concerns::HasModelsWithoutTemperatureFallback
|
||||
|
||||
DEFAULT_OPTIONS = {
|
||||
temperature: 0.1,
|
||||
temperature: 0.1,
|
||||
models_without_temperature: ['gpt-5']
|
||||
}.freeze
|
||||
|
||||
def chat(prompt_system:, prompt_user:, prompt_image:)
|
||||
@@ -15,7 +17,7 @@ class AI::Provider::CustomOpenAI < AI::Provider
|
||||
stream: false,
|
||||
}
|
||||
|
||||
request_body[:temperature] = options[:temperature]
|
||||
request_body[:temperature] = options[:temperature] if model_supports_temperature?
|
||||
|
||||
request_options = {
|
||||
open_timeout: 4,
|
||||
@@ -74,6 +76,50 @@ class AI::Provider::CustomOpenAI < AI::Provider
|
||||
nil
|
||||
end
|
||||
|
||||
def self.check_temperature_support!(config)
|
||||
request_body = {
|
||||
model: config[:model],
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
temperature: DEFAULT_OPTIONS[:temperature],
|
||||
stream: false,
|
||||
}
|
||||
|
||||
request_options = {
|
||||
open_timeout: 4,
|
||||
read_timeout: 60,
|
||||
verify_ssl: true,
|
||||
total_timeout: 60,
|
||||
json: true,
|
||||
log: {
|
||||
facility: 'AI::Provider',
|
||||
log_only_on_error: true,
|
||||
},
|
||||
}
|
||||
|
||||
# Token is optional since target host might not require authentication
|
||||
request_options[:bearer_token] = config[:token] if config[:token].present?
|
||||
|
||||
response = UserAgent.post(
|
||||
"#{config[:url]}/chat/completions",
|
||||
request_body,
|
||||
request_options,
|
||||
)
|
||||
|
||||
return true if response.success?
|
||||
|
||||
data = JSON.parse(response.body)
|
||||
data = data.pop if data.is_a?(Array) # Handle case when response is an array of errors
|
||||
message = data.dig('error', 'message')
|
||||
type = data.dig('error', 'type')
|
||||
param = data.dig('error', 'param')
|
||||
code = data.dig('error', 'code')
|
||||
return false if type == 'invalid_request_error' && param == 'temperature' && code == 'unsupported_value'
|
||||
|
||||
raise message
|
||||
rescue => e
|
||||
raise CheckTemperatureSupportError, e.message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def specific_metadata
|
||||
|
||||
@@ -18,16 +18,17 @@ class AI::Provider::Ollama < AI::Provider
|
||||
|
||||
def chat(prompt_system:, prompt_user:, prompt_image:)
|
||||
params = {
|
||||
model: model_for(prompt_image:),
|
||||
system: prompt_system,
|
||||
prompt: prompt_user,
|
||||
stream: false,
|
||||
think: false,
|
||||
options: {
|
||||
temperature: options[:temperature],
|
||||
},
|
||||
model: model_for(prompt_image:),
|
||||
system: prompt_system,
|
||||
prompt: prompt_user,
|
||||
stream: false,
|
||||
think: false,
|
||||
}
|
||||
|
||||
if model_supports_temperature?
|
||||
params[:options] = { temperature: options[:temperature] }
|
||||
end
|
||||
|
||||
if prompt_image.is_a?(::Store)
|
||||
params[:images] = [Base64.strict_encode64(prompt_image.content_ocr)]
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
class AI::Provider::OpenAI < AI::Provider
|
||||
include AI::Provider::Concerns::HandlesOpenAIMessages
|
||||
include AI::Provider::Concerns::HasConfigurableModel
|
||||
include AI::Provider::Concerns::HasModelsWithoutTemperatureFallback
|
||||
|
||||
OPENAI_API_BASE_URL = 'https://api.openai.com/v1'.freeze
|
||||
|
||||
@@ -98,15 +99,46 @@ class AI::Provider::OpenAI < AI::Provider
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
def self.check_temperature_support!(config)
|
||||
response = UserAgent.post(
|
||||
"#{OPENAI_API_BASE_URL}/chat/completions",
|
||||
{
|
||||
model: config[:model] || DEFAULT_OPTIONS[:model],
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
temperature: DEFAULT_OPTIONS[:temperature],
|
||||
stream: false,
|
||||
store: false,
|
||||
},
|
||||
{
|
||||
open_timeout: 4,
|
||||
read_timeout: 60,
|
||||
verify_ssl: true,
|
||||
bearer_token: config[:token],
|
||||
total_timeout: 60,
|
||||
json: true,
|
||||
log: {
|
||||
facility: 'AI::Provider',
|
||||
log_only_on_error: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def model_supports_temperature?
|
||||
current_model = options[:model]
|
||||
return true if response.success?
|
||||
|
||||
# Check if any model in the list starts with the current model name
|
||||
options[:models_without_temperature].none? { |model_pattern| current_model.start_with?(model_pattern) }
|
||||
data = JSON.parse(response.body)
|
||||
message = data.dig('error', 'message')
|
||||
type = data.dig('error', 'type')
|
||||
param = data.dig('error', 'param')
|
||||
code = data.dig('error', 'code')
|
||||
return false if type == 'invalid_request_error' && param == 'temperature' && code == 'unsupported_value'
|
||||
|
||||
raise message
|
||||
rescue => e
|
||||
raise CheckTemperatureSupportError, e.message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def specific_metadata
|
||||
{
|
||||
model: options[:model],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
require 'rails_helper'
|
||||
require_relative 'shared_examples/ping'
|
||||
require_relative 'shared_examples/check_temperature_support'
|
||||
|
||||
# TODO: Add AZURE_URL_EMBEDDINGS when needed.
|
||||
RSpec.describe AI::Provider::Azure, integration: true, required_envs: %w[AZURE_TOKEN AZURE_URL_COMPLETIONS AZURE_HOST], use_vcr: true do
|
||||
@@ -23,6 +24,7 @@ RSpec.describe AI::Provider::Azure, integration: true, required_envs: %w[AZURE_T
|
||||
end
|
||||
|
||||
include_examples 'provider/ping!'
|
||||
include_examples 'provider/check_temperature_support'
|
||||
|
||||
it 'does exchange data with azure ai endpoint' do
|
||||
expect(ai_provider.ask(prompt_system:, prompt_user:)).to match({ 'connected' => 'true' })
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
require 'rails_helper'
|
||||
require_relative 'shared_examples/ping'
|
||||
require_relative 'shared_examples/check_temperature_support'
|
||||
|
||||
RSpec.describe AI::Provider::OpenAI, integration: true, required_envs: %w[OPEN_AI_TOKEN], use_vcr: true do
|
||||
subject(:ai_provider) { described_class.new(options: { json_response: true }) }
|
||||
@@ -18,6 +19,7 @@ RSpec.describe AI::Provider::OpenAI, integration: true, required_envs: %w[OPEN_A
|
||||
end
|
||||
|
||||
include_examples 'provider/ping!'
|
||||
include_examples 'provider/check_temperature_support'
|
||||
|
||||
context 'when specifying a model' do
|
||||
context 'without a model' do
|
||||
@@ -44,6 +46,16 @@ RSpec.describe AI::Provider::OpenAI, integration: true, required_envs: %w[OPEN_A
|
||||
expect(ai_provider.ask(prompt_system:, prompt_user:)).to match({ 'connected' => 'true' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with model_temperature_support flag set to false' do
|
||||
before do
|
||||
Setting.set('ai_provider_config', Setting.get('ai_provider_config').merge(model_temperature_support: false))
|
||||
end
|
||||
|
||||
it 'does exchange data with open ai endpoint without temperature' do
|
||||
expect(ai_provider.ask(prompt_system:, prompt_user:)).to match({ 'connected' => 'true' })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an invalid model' do
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
RSpec.shared_examples 'provider/check_temperature_support' do
|
||||
describe '.check_temperature_support!' do
|
||||
let(:provider_config) { Setting.get('ai_provider_config') }
|
||||
|
||||
context 'when API accepts temperature' do
|
||||
before do
|
||||
allow(UserAgent).to receive(:post).and_return(
|
||||
UserAgent::Result.new(
|
||||
success: true,
|
||||
code: 200,
|
||||
data: {},
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(described_class.check_temperature_support!(provider_config)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API rejects temperature' do
|
||||
before do
|
||||
allow(UserAgent).to receive(:post).and_return(
|
||||
UserAgent::Result.new(
|
||||
body: '{ "error": { "message": "Unsupported value: \'temperature\' does not support 0.1 with this model. Only the default (1) value is supported.", "type": "invalid_request_error", "param": "temperature", "code": "unsupported_value" } }',
|
||||
success: false,
|
||||
code: 400,
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(described_class.check_temperature_support!(provider_config)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an exception occurs' do
|
||||
before do
|
||||
allow(UserAgent).to receive(:post).and_raise(StandardError, 'connection error')
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { described_class.check_temperature_support!(provider_config) }.to raise_error(AI::Provider::CheckTemperatureSupportError, 'connection error')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
RSpec.shared_examples 'provider/model_supports_temperature' do
|
||||
describe 'temperature support flag' do
|
||||
let(:prompt_system) { '' }
|
||||
let(:prompt_user) { 'Hi' }
|
||||
|
||||
before do
|
||||
allow(UserAgent).to receive(:post).and_return(
|
||||
UserAgent::Result.new(
|
||||
success: true,
|
||||
code: 200,
|
||||
data: chat_response_data,
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
context 'when model_temperature_support is false' do
|
||||
before do
|
||||
Setting.set('ai_provider_config', Setting.get('ai_provider_config').merge(model_temperature_support: false), validate: false)
|
||||
end
|
||||
|
||||
it 'does not include temperature in the request', :aggregate_failures do
|
||||
subject.ask(prompt_system:, prompt_user:)
|
||||
|
||||
request_body = nil
|
||||
expect(UserAgent).to have_received(:post) do |_url, body, _options|
|
||||
request_body = body
|
||||
end
|
||||
|
||||
expect(request_body).not_to include(:temperature)
|
||||
expect(request_body.dig(:options, :temperature)).to be_nil if request_body.key?(:options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -182,6 +182,7 @@ RSpec.describe Setting::Validation::AIProviderConfig do
|
||||
|
||||
raise AI::Provider::ResponseError, 'API server not accessible'
|
||||
end
|
||||
allow(AI::Provider::OpenAI).to receive(:check_temperature_support!).and_return(true)
|
||||
end
|
||||
|
||||
context 'with missing token' do
|
||||
@@ -211,4 +212,55 @@ RSpec.describe Setting::Validation::AIProviderConfig do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'temperature support check' do
|
||||
let(:config) { { provider: 'open_ai', token: 'valid' } }
|
||||
|
||||
before do
|
||||
allow(AI::Provider::OpenAI).to receive(:ping!)
|
||||
end
|
||||
|
||||
context 'when temperature is supported' do
|
||||
before do
|
||||
allow(AI::Provider::OpenAI).to receive(:check_temperature_support!).and_return(true)
|
||||
end
|
||||
|
||||
it 'persists model_temperature_support as true' do
|
||||
Setting.set(setting_name, config)
|
||||
|
||||
expect(Setting.get(setting_name)).to include('model_temperature_support' => true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when temperature is not supported' do
|
||||
before do
|
||||
allow(AI::Provider::OpenAI).to receive(:check_temperature_support!).and_return(false)
|
||||
end
|
||||
|
||||
it 'persists model_temperature_support as false' do
|
||||
Setting.set(setting_name, config)
|
||||
|
||||
expect(Setting.get(setting_name)).to include('model_temperature_support' => false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when temperature check raises an error' do
|
||||
before do
|
||||
allow(AI::Provider::OpenAI).to receive(:check_temperature_support!).and_raise(AI::Provider::CheckTemperatureSupportError, 'check failed')
|
||||
end
|
||||
|
||||
it 'does cause validation failure' do
|
||||
expect { Setting.set(setting_name, config) }
|
||||
.to raise_error(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
end
|
||||
|
||||
it 'calls check_temperature_support! after successful ping' do
|
||||
allow(AI::Provider::OpenAI).to receive(:check_temperature_support!).and_return(true)
|
||||
|
||||
Setting.set(setting_name, config)
|
||||
|
||||
expect(AI::Provider::OpenAI).to have_received(:check_temperature_support!)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -70,7 +70,7 @@ RSpec.describe 'AI > Provider', authenticated_as: :admin, type: :system do
|
||||
.and(have_field('OCR Model'))
|
||||
check_switch_field_value('ocr_active', false)
|
||||
|
||||
find('select[name=provider]').select('Azure AI')
|
||||
find('select[name=provider]').select('Azure AI (legacy deployment-based endpoints)')
|
||||
expect(page)
|
||||
.to have_field('URL')
|
||||
.and(have_field('Token'))
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
http_interactions:
|
||||
- request:
|
||||
method: post
|
||||
uri: https://api.openai.com/v1/chat/completions
|
||||
body:
|
||||
encoding: UTF-8
|
||||
string: '{"model":"gpt-4.1","messages":[{"role":"user","content":"This is a
|
||||
connection test. Return in unprettified JSON ''{ \"connected\": \"true\" }''
|
||||
if you got the message. Respond in plain JSON format only and do not wrap
|
||||
it in code block markers."}],"response_format":{"type":"json_object"},"stream":false,"store":false}'
|
||||
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
|
||||
Content-Type:
|
||||
- application/json; charset=utf-8
|
||||
Authorization:
|
||||
- Bearer <OPEN_AI_TOKEN>
|
||||
response:
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
headers:
|
||||
Date:
|
||||
- Mon, 06 Apr 2026 09:25:01 GMT
|
||||
Content-Type:
|
||||
- application/json
|
||||
Transfer-Encoding:
|
||||
- chunked
|
||||
Connection:
|
||||
- keep-alive
|
||||
Cf-Ray:
|
||||
- 9e7fbe22c8dc3477-MAN
|
||||
Cf-Cache-Status:
|
||||
- DYNAMIC
|
||||
Server:
|
||||
- cloudflare
|
||||
Strict-Transport-Security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
Access-Control-Expose-Headers:
|
||||
- X-Request-ID
|
||||
Openai-Organization:
|
||||
- zammad-gmbh
|
||||
Openai-Processing-Ms:
|
||||
- '335'
|
||||
Openai-Project:
|
||||
- proj_3ITPzFJI71Ao0y739ux6T85o
|
||||
Openai-Version:
|
||||
- '2020-10-01'
|
||||
X-Openai-Proxy-Wasm:
|
||||
- v0.1
|
||||
X-Ratelimit-Limit-Requests:
|
||||
- '5000'
|
||||
X-Ratelimit-Limit-Tokens:
|
||||
- '800000'
|
||||
X-Ratelimit-Remaining-Requests:
|
||||
- '4999'
|
||||
X-Ratelimit-Remaining-Tokens:
|
||||
- '799953'
|
||||
X-Ratelimit-Reset-Requests:
|
||||
- 12ms
|
||||
X-Ratelimit-Reset-Tokens:
|
||||
- 3ms
|
||||
X-Request-Id:
|
||||
- req_e23447a583ab427b89185a2397323324
|
||||
Set-Cookie:
|
||||
- __cf_bm=tNzJDqUIA1cyn6KMNdHJXQlJoToMv8V7.kY3Fe9JslE-1775467499.9651556-1.0.1.1-wutSST9yqPpq0YHxnLaSQWz5nzpwyKmHCr7zVbI8ZxBcqKdO3w_wUP8SEhnckpECT6GWaZTsXxUxhMahwtMhW2X2rXc1VmpvPZ.vHSUXIbtEUYbMhxJh1p68h7b0MlLh;
|
||||
HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Mon, 06 Apr 2026
|
||||
09:55:01 GMT
|
||||
Alt-Svc:
|
||||
- h3=":443"; ma=86400
|
||||
body:
|
||||
encoding: ASCII-8BIT
|
||||
string: |
|
||||
{
|
||||
"id": "chatcmpl-DRaUW8SLWk9pnnuET4BB6siBDEK4c",
|
||||
"object": "chat.completion",
|
||||
"created": 1775467500,
|
||||
"model": "gpt-4.1-2025-04-14",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "{ \"connected\": \"true\" }",
|
||||
"refusal": null,
|
||||
"annotations": []
|
||||
},
|
||||
"logprobs": null,
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 51,
|
||||
"completion_tokens": 8,
|
||||
"total_tokens": 59,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0,
|
||||
"audio_tokens": 0
|
||||
},
|
||||
"completion_tokens_details": {
|
||||
"reasoning_tokens": 0,
|
||||
"audio_tokens": 0,
|
||||
"accepted_prediction_tokens": 0,
|
||||
"rejected_prediction_tokens": 0
|
||||
}
|
||||
},
|
||||
"service_tier": "default",
|
||||
"system_fingerprint": "fp_966651883e"
|
||||
}
|
||||
recorded_at: Mon, 06 Apr 2026 09:25:01 GMT
|
||||
recorded_with: VCR 6.4.0
|
||||
Reference in New Issue
Block a user