Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fd1a5c2ef | ||
|
|
7793e4645f |
@@ -12,6 +12,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
@installation_configs = ConfigLoader.new.general_configs.each_with_object({}) do |config_hash, result|
|
||||
result[config_hash['name']] = config_hash.except('name')
|
||||
end
|
||||
populate_captain_config_metadata if @config == 'captain'
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -20,7 +21,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
next unless @allowed_configs.include?(key)
|
||||
|
||||
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
|
||||
i.value = value
|
||||
i.value = app_config_value(key, value)
|
||||
errors.concat(i.errors.full_messages) unless i.save
|
||||
end
|
||||
|
||||
@@ -71,19 +72,62 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
end
|
||||
|
||||
def restart_required_config_saved?
|
||||
params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
|
||||
saved_keys = params.fetch('app_config', {}).keys
|
||||
saved_keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS) ||
|
||||
saved_keys.intersect?(Llm::Config.provider_config_keys(captain_provider))
|
||||
end
|
||||
|
||||
def captain_config_options
|
||||
%w[
|
||||
CAPTAIN_OPEN_AI_API_KEY
|
||||
CAPTAIN_OPEN_AI_MODEL
|
||||
CAPTAIN_OPEN_AI_ENDPOINT
|
||||
CAPTAIN_ANTHROPIC_API_KEY
|
||||
CAPTAIN_ANTHROPIC_API_BASE
|
||||
CAPTAIN_GEMINI_API_KEY
|
||||
CAPTAIN_GEMINI_API_BASE
|
||||
]
|
||||
Llm::Config.provider_config_keys(captain_provider)
|
||||
end
|
||||
|
||||
def populate_captain_config_metadata
|
||||
@app_config[Llm::ProviderConfig::PROVIDER_CONFIG_KEY] = captain_provider
|
||||
|
||||
populate_provider_metadata
|
||||
populate_model_metadata
|
||||
populate_provider_field_metadata
|
||||
end
|
||||
|
||||
def populate_provider_metadata
|
||||
@installation_configs[Llm::ProviderConfig::PROVIDER_CONFIG_KEY] = {
|
||||
'display_title' => 'LLM Provider',
|
||||
'description' => 'Provider used by Captain AI for chat and generation features.',
|
||||
'type' => 'select',
|
||||
'options' => Llm::Config.provider_options
|
||||
}
|
||||
end
|
||||
|
||||
def populate_model_metadata
|
||||
@installation_configs[Llm::ProviderConfig::MODEL_CONFIG_KEY] = {
|
||||
'display_title' => 'LLM Model',
|
||||
'description' => "Default #{Llm::Config.provider_options[captain_provider]} model used by Captain AI.",
|
||||
'type' => 'select',
|
||||
'options' => Llm::Models.provider_default_model_options(captain_provider)
|
||||
}
|
||||
end
|
||||
|
||||
def populate_provider_field_metadata
|
||||
Llm::Config.provider_config_options(captain_provider).each do |option, config_key|
|
||||
@installation_configs[config_key] ||= {
|
||||
'display_title' => option.to_s.humanize.titleize,
|
||||
'description' => "RubyLLM #{option} configuration.",
|
||||
'type' => option.to_s.end_with?('api_key', 'secret_key', 'session_token', 'service_account_key', 'auth_token') ? 'secret' : 'text',
|
||||
'provider' => captain_provider
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def captain_provider
|
||||
requested_provider = params.dig(:app_config, Llm::ProviderConfig::PROVIDER_CONFIG_KEY).presence || params[:provider].presence
|
||||
return requested_provider if Llm::Config.provider_options.key?(requested_provider)
|
||||
|
||||
Llm::Config.current_provider
|
||||
end
|
||||
|
||||
def app_config_value(key, value)
|
||||
return value unless @config == 'captain' && key == Llm::ProviderConfig::MODEL_CONFIG_KEY
|
||||
return value if Llm::Models.provider_default_model_options(captain_provider).key?(value)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ class InstallationConfig < ApplicationRecord
|
||||
CAPTAIN_OPEN_AI_API_KEY
|
||||
CAPTAIN_OPEN_AI_ENDPOINT
|
||||
CAPTAIN_OPEN_AI_MODEL
|
||||
CAPTAIN_LLM_PROVIDER
|
||||
CAPTAIN_LLM_MODEL
|
||||
CAPTAIN_ANTHROPIC_API_KEY
|
||||
CAPTAIN_ANTHROPIC_API_BASE
|
||||
CAPTAIN_GEMINI_API_KEY
|
||||
|
||||
@@ -95,6 +95,15 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const captainProviderSelect = document.querySelector('select[name="app_config[CAPTAIN_LLM_PROVIDER]"]');
|
||||
if (captainProviderSelect) {
|
||||
captainProviderSelect.addEventListener('change', () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('provider', captainProviderSelect.value);
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<% end %>
|
||||
|
||||
@@ -189,12 +189,23 @@
|
||||
type: secret
|
||||
- name: CAPTAIN_OPEN_AI_MODEL
|
||||
display_title: 'OpenAI Model'
|
||||
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4.1-mini'
|
||||
description: 'Legacy OpenAI model setting for Captain AI. Use LLM Model for provider-aware defaults.'
|
||||
locked: false
|
||||
- name: CAPTAIN_OPEN_AI_ENDPOINT
|
||||
display_title: 'OpenAI API Endpoint (optional)'
|
||||
description: 'The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/'
|
||||
locked: false
|
||||
- name: CAPTAIN_LLM_PROVIDER
|
||||
display_title: 'LLM Provider'
|
||||
description: 'Provider used by Captain AI for chat and generation features.'
|
||||
value: openai
|
||||
locked: false
|
||||
type: select
|
||||
- name: CAPTAIN_LLM_MODEL
|
||||
display_title: 'LLM Model'
|
||||
description: 'Default model used by Captain AI for chat and generation features.'
|
||||
locked: false
|
||||
type: select
|
||||
- name: CAPTAIN_ANTHROPIC_API_KEY
|
||||
display_title: 'Anthropic API Key'
|
||||
description: 'The API key used to authenticate requests to Anthropic models for Captain AI.'
|
||||
|
||||
@@ -41,14 +41,7 @@ module Enterprise::SuperAdmin::AppConfigsController
|
||||
end
|
||||
|
||||
def captain_config_options
|
||||
%w[
|
||||
CAPTAIN_OPEN_AI_API_KEY
|
||||
CAPTAIN_OPEN_AI_MODEL
|
||||
CAPTAIN_OPEN_AI_ENDPOINT
|
||||
CAPTAIN_ANTHROPIC_API_KEY
|
||||
CAPTAIN_ANTHROPIC_API_BASE
|
||||
CAPTAIN_GEMINI_API_KEY
|
||||
CAPTAIN_GEMINI_API_BASE
|
||||
super + %w[
|
||||
CAPTAIN_EMBEDDING_MODEL
|
||||
CAPTAIN_FIRECRAWL_API_KEY
|
||||
]
|
||||
|
||||
@@ -44,13 +44,7 @@ module Concerns::Agentable
|
||||
|
||||
def agent_model
|
||||
route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account)
|
||||
return route[:model] if route[:source] == :account_override
|
||||
|
||||
installation_model.presence || route[:model]
|
||||
end
|
||||
|
||||
def installation_model
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
|
||||
route[:model]
|
||||
end
|
||||
|
||||
def agent_response_schema
|
||||
|
||||
@@ -17,7 +17,11 @@ class Captain::Llm::EmbeddingService
|
||||
return [] if content.blank?
|
||||
|
||||
instrument_embedding_call(instrumentation_params(content, model)) do
|
||||
RubyLLM.embed(content, model: model).vectors
|
||||
raise EmbeddingsError, 'OpenAI configuration is required for embeddings.' unless Llm::Config.provider_configured?(Llm::Config::DEFAULT_PROVIDER)
|
||||
|
||||
Llm::Config.with_provider(provider: Llm::Config::DEFAULT_PROVIDER) do |context|
|
||||
context.embed(content, model: model, provider: Llm::Config::DEFAULT_PROVIDER, assume_model_exists: true).vectors
|
||||
end
|
||||
end
|
||||
rescue RubyLLM::Error => e
|
||||
Rails.logger.error "Embedding API Error: #{e.message}"
|
||||
|
||||
@@ -40,7 +40,7 @@ class Llm::BaseAiService
|
||||
return setup_provider(route)
|
||||
end
|
||||
|
||||
@model = @fallback_model.presence || installation_model.presence || route&.dig(:model) || DEFAULT_MODEL
|
||||
@model = @fallback_model.presence || route&.dig(:model) || Llm::Models.installation_model || DEFAULT_MODEL
|
||||
setup_provider(route)
|
||||
end
|
||||
|
||||
@@ -54,10 +54,6 @@ class Llm::BaseAiService
|
||||
route&.dig(:source) == :account_override
|
||||
end
|
||||
|
||||
def installation_model
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
|
||||
end
|
||||
|
||||
def setup_provider(route)
|
||||
@provider = provider_for_model(@model, route&.dig(:provider))
|
||||
end
|
||||
|
||||
@@ -16,7 +16,7 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
|
||||
return default_incomplete_response('No messages found') if content.blank?
|
||||
|
||||
response = make_api_call(
|
||||
model: InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL,
|
||||
model: Llm::Models.installation_model || GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt_from_file('conversation_completion') },
|
||||
{ role: 'user', content: content }
|
||||
@@ -58,8 +58,9 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
|
||||
|
||||
# This is an internal operational evaluation, not a customer-triggered feature,
|
||||
# so it should always use the installation key.
|
||||
def llm_credential
|
||||
@llm_credential ||= system_llm_credential
|
||||
def llm_credential(provider = llm_provider)
|
||||
@llm_credentials ||= {}
|
||||
@llm_credentials[provider.to_s] ||= system_llm_credential(provider)
|
||||
end
|
||||
|
||||
def counts_toward_usage?
|
||||
|
||||
@@ -76,7 +76,7 @@ class Captain::BaseTaskService
|
||||
provider = llm_route[:provider]
|
||||
credential = llm_credential(provider)
|
||||
|
||||
Llm::Config.with_api_key(credential[:api_key], provider: provider, api_base: api_base) do |context|
|
||||
Llm::Config.with_provider(provider: provider, config_values: credential[:config_values]) do |context|
|
||||
chat = build_chat(context, llm_route: llm_route, messages: messages, schema: schema, tools: tools)
|
||||
|
||||
conversation_messages = messages.reject { |m| m[:role] == 'system' }
|
||||
|
||||
@@ -103,7 +103,7 @@ class Integrations::LlmBaseService
|
||||
credential = llm_credential
|
||||
return { error: I18n.t('captain.api_key_missing'), error_code: 401, request_messages: messages } if credential.blank?
|
||||
|
||||
Llm::Config.with_api_key(credential[:api_key], provider: llm_provider, api_base: api_base) do |context|
|
||||
Llm::Config.with_provider(provider: llm_provider, config_values: credential[:config_values]) do |context|
|
||||
chat = Llm::ProviderChat.new(context.chat(model: model, provider: llm_provider, assume_model_exists: true), provider: llm_provider)
|
||||
setup_chat_with_messages(chat, messages)
|
||||
end
|
||||
|
||||
+16
-89
@@ -1,24 +1,12 @@
|
||||
require 'ruby_llm'
|
||||
require_relative 'provider_config'
|
||||
|
||||
module Llm::Config
|
||||
extend Llm::ProviderConfig
|
||||
|
||||
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
|
||||
DEFAULT_PROVIDER = 'openai'.freeze
|
||||
|
||||
PROVIDER_CONFIGS = {
|
||||
'openai' => {
|
||||
api_key: 'CAPTAIN_OPEN_AI_API_KEY',
|
||||
api_base: 'CAPTAIN_OPEN_AI_ENDPOINT'
|
||||
},
|
||||
'anthropic' => {
|
||||
api_key: 'CAPTAIN_ANTHROPIC_API_KEY',
|
||||
api_base: 'CAPTAIN_ANTHROPIC_API_BASE'
|
||||
},
|
||||
'gemini' => {
|
||||
api_key: 'CAPTAIN_GEMINI_API_KEY',
|
||||
api_base: 'CAPTAIN_GEMINI_API_BASE'
|
||||
}
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
def initialized? = @initialized ||= false
|
||||
|
||||
@@ -31,99 +19,38 @@ module Llm::Config
|
||||
|
||||
def reset! = @initialized = false
|
||||
|
||||
def with_api_key(api_key, provider: DEFAULT_PROVIDER, api_base: nil)
|
||||
def with_api_key(api_key, provider: DEFAULT_PROVIDER, api_base: nil, config_values: nil)
|
||||
initialize!
|
||||
context = RubyLLM.context do |config|
|
||||
configure_provider(config, provider: provider, api_key: api_key, api_base: api_base)
|
||||
values = config_values || provider_config_values(provider).merge(
|
||||
:"#{provider}_api_key" => api_key,
|
||||
:"#{provider}_api_base" => api_base
|
||||
).compact
|
||||
configure_provider(config, provider: provider, config_values: values)
|
||||
end
|
||||
|
||||
yield context
|
||||
end
|
||||
|
||||
def ruby_llm_provider_supported?(provider)
|
||||
RubyLLM::Provider.providers.key?(provider.to_s.to_sym)
|
||||
end
|
||||
|
||||
def provider_options
|
||||
PROVIDER_CONFIGS.keys.each_with_object({}) do |provider, result|
|
||||
next unless ruby_llm_provider_supported?(provider)
|
||||
|
||||
result[provider] = ruby_llm_provider_name(provider)
|
||||
def with_provider(provider:, config_values: provider_config_values(provider))
|
||||
initialize!
|
||||
context = RubyLLM.context do |config|
|
||||
configure_provider(config, provider: provider, config_values: config_values)
|
||||
end
|
||||
end
|
||||
|
||||
def api_key_for(provider)
|
||||
installation_config_value(provider, :api_key)
|
||||
end
|
||||
|
||||
def api_base_for(provider)
|
||||
api_base = installation_config_value(provider, :api_base).presence
|
||||
return if api_base.blank?
|
||||
|
||||
normalized_api_base(provider, api_base)
|
||||
end
|
||||
|
||||
def provider_configured?(provider)
|
||||
api_key_for(provider).present?
|
||||
end
|
||||
|
||||
def openai_provider?(provider)
|
||||
provider.to_s == DEFAULT_PROVIDER
|
||||
end
|
||||
|
||||
def supports_tools_and_schema?(provider)
|
||||
openai_provider?(provider)
|
||||
end
|
||||
|
||||
def configure_provider(config, provider:, api_key:, api_base: nil)
|
||||
provider = provider.to_s
|
||||
options = provider_configuration_options(provider)
|
||||
api_key_option = :"#{provider}_api_key"
|
||||
api_base_option = :"#{provider}_api_base"
|
||||
|
||||
set_config_value(config, api_key_option, api_key) if api_key.present? && options.include?(api_key_option)
|
||||
set_config_value(config, api_base_option, api_base) if api_base.present? && options.include?(api_base_option)
|
||||
yield context
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def configure_ruby_llm
|
||||
RubyLLM.configure do |config|
|
||||
PROVIDER_CONFIGS.each_key do |provider|
|
||||
next unless ruby_llm_provider_supported?(provider)
|
||||
|
||||
configure_provider(config, provider: provider, api_key: api_key_for(provider), api_base: api_base_for(provider))
|
||||
provider_options.each_key do |provider|
|
||||
configure_provider(config, provider: provider, config_values: provider_config_values(provider))
|
||||
end
|
||||
config.model_registry_file = Rails.root.join('config/llm_models.json').to_s
|
||||
config.logger = Rails.logger
|
||||
end
|
||||
end
|
||||
|
||||
def ruby_llm_provider_name(provider)
|
||||
RubyLLM::Provider.providers[provider.to_s.to_sym].name
|
||||
end
|
||||
|
||||
def provider_configuration_options(provider)
|
||||
RubyLLM::Provider.providers[provider.to_s.to_sym]&.configuration_options || []
|
||||
end
|
||||
|
||||
def set_config_value(config, option, value)
|
||||
setter = :"#{option}="
|
||||
config.public_send(setter, value) if config.respond_to?(setter)
|
||||
end
|
||||
|
||||
def installation_config_value(provider, key)
|
||||
config_name = PROVIDER_CONFIGS.dig(provider.to_s, key)
|
||||
return if config_name.blank?
|
||||
|
||||
InstallationConfig.find_by(name: config_name)&.value
|
||||
end
|
||||
|
||||
def normalized_api_base(provider, api_base)
|
||||
endpoint = api_base.chomp('/').delete_suffix('/chat/completions')
|
||||
return "#{endpoint}/v1" if openai_provider?(provider) && endpoint.exclude?('/v1')
|
||||
|
||||
endpoint
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,11 +16,13 @@ class Llm::CredentialResolver
|
||||
return unless Llm::Config.openai_provider?(provider)
|
||||
|
||||
key = openai_hook&.settings&.dig('api_key').presence
|
||||
{ api_key: key, provider: provider, source: :hook } if key
|
||||
{ api_key: key, config_values: { openai_api_key: key }, provider: provider, source: :hook } if key
|
||||
end
|
||||
|
||||
def system_llm_credential
|
||||
key = Llm::Config.api_key_for(provider).presence
|
||||
{ api_key: key, provider: provider, source: :system } if key
|
||||
config_values = Llm::Config.provider_config_values(provider)
|
||||
return unless Llm::Config.provider_configured?(provider)
|
||||
|
||||
{ api_key: config_values[:"#{provider}_api_key"], config_values: config_values, provider: provider, source: :system }
|
||||
end
|
||||
end
|
||||
|
||||
+45
-11
@@ -1,8 +1,16 @@
|
||||
require_relative 'provider_model_catalog'
|
||||
|
||||
module Llm::Models
|
||||
extend Llm::ProviderModelCatalog
|
||||
|
||||
CONFIG = YAML.load_file(Rails.root.join('config/llm.yml')).freeze
|
||||
OPENAI_ONLY_FEATURES = %w[audio_transcription help_center_search].freeze
|
||||
|
||||
class << self
|
||||
def providers = CONFIG.fetch('providers')
|
||||
def providers
|
||||
Llm::Config.provider_options.transform_values { |display_name| { 'display_name' => display_name } }
|
||||
end
|
||||
|
||||
def models = CONFIG.fetch('models')
|
||||
def features = CONFIG.fetch('features')
|
||||
def feature_keys = features.keys
|
||||
@@ -12,26 +20,31 @@ module Llm::Models
|
||||
end
|
||||
|
||||
def default_model_for(feature)
|
||||
default_model = features.dig(feature.to_s, 'default')
|
||||
return default_model if supported_model?(default_model)
|
||||
installation_default = installation_model
|
||||
return installation_default if valid_model_for?(feature, installation_default)
|
||||
|
||||
feature_default = features.dig(feature.to_s, 'default')
|
||||
return feature_default if valid_model_for?(feature, feature_default)
|
||||
|
||||
models_for(feature).first
|
||||
end
|
||||
|
||||
def models_for(feature)
|
||||
(features.dig(feature.to_s, 'models') || []).select { |model_name| supported_model?(model_name) }
|
||||
def models_for(feature, provider: provider_for_feature(feature))
|
||||
(configured_models_for(feature, provider: provider) + provider_models_for(feature, provider: provider)).uniq
|
||||
end
|
||||
|
||||
def valid_model_for?(feature, model_name)
|
||||
models_for(feature).include?(model_name.to_s)
|
||||
def valid_model_for?(feature, model_name, provider: provider_for_feature(feature))
|
||||
return false if model_name.blank?
|
||||
|
||||
models_for(feature, provider: provider).include?(model_name.to_s)
|
||||
end
|
||||
|
||||
def model_config(model_name)
|
||||
models[model_name.to_s]
|
||||
models[model_name.to_s] || ruby_llm_model_config(model_name)
|
||||
end
|
||||
|
||||
def provider_for(model_name)
|
||||
model_config(model_name)&.dig('provider')
|
||||
models.dig(model_name.to_s, 'provider') || ruby_llm_model(model_name)&.provider
|
||||
end
|
||||
|
||||
def supported_provider?(provider)
|
||||
@@ -49,8 +62,10 @@ module Llm::Models
|
||||
feature = features[feature_key.to_s]
|
||||
return nil unless feature
|
||||
|
||||
provider = provider_for_feature(feature_key)
|
||||
|
||||
{
|
||||
models: models_for(feature_key).map do |model_name|
|
||||
models: models_for(feature_key, provider: provider).map do |model_name|
|
||||
model = model_config(model_name)
|
||||
{
|
||||
id: model_name,
|
||||
@@ -60,8 +75,27 @@ module Llm::Models
|
||||
credit_multiplier: model['credit_multiplier']
|
||||
}
|
||||
end,
|
||||
default: feature['default']
|
||||
default: default_model_for(feature_key),
|
||||
provider: provider
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ruby_llm_model_config(model_name)
|
||||
model = ruby_llm_model(model_name)
|
||||
return unless model
|
||||
|
||||
{
|
||||
'provider' => model.provider,
|
||||
'display_name' => model.name
|
||||
}
|
||||
end
|
||||
|
||||
def ruby_llm_model(model_name)
|
||||
RubyLLM.models.find(model_name.to_s)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
require 'ruby_llm'
|
||||
|
||||
module Llm::ProviderConfig
|
||||
PROVIDER_CONFIG_PREFIX = 'CAPTAIN_LLM'.freeze
|
||||
PROVIDER_CONFIG_KEY = 'CAPTAIN_LLM_PROVIDER'.freeze
|
||||
MODEL_CONFIG_KEY = 'CAPTAIN_LLM_MODEL'.freeze
|
||||
LEGACY_OPENAI_MODEL_CONFIG_KEY = 'CAPTAIN_OPEN_AI_MODEL'.freeze
|
||||
|
||||
LEGACY_CONFIG_KEYS = {
|
||||
openai_api_key: 'CAPTAIN_OPEN_AI_API_KEY',
|
||||
openai_api_base: 'CAPTAIN_OPEN_AI_ENDPOINT',
|
||||
anthropic_api_key: 'CAPTAIN_ANTHROPIC_API_KEY',
|
||||
anthropic_api_base: 'CAPTAIN_ANTHROPIC_API_BASE',
|
||||
gemini_api_key: 'CAPTAIN_GEMINI_API_KEY',
|
||||
gemini_api_base: 'CAPTAIN_GEMINI_API_BASE'
|
||||
}.freeze
|
||||
|
||||
def ruby_llm_provider_supported?(provider)
|
||||
RubyLLM::Provider.providers.key?(provider.to_s.to_sym)
|
||||
end
|
||||
|
||||
def provider_options
|
||||
RubyLLM::Provider.providers.keys.map(&:to_s).sort.index_with do |provider|
|
||||
ruby_llm_provider_name(provider)
|
||||
end
|
||||
end
|
||||
|
||||
def provider_config_keys(provider = nil)
|
||||
([PROVIDER_CONFIG_KEY, MODEL_CONFIG_KEY] + provider_config_options(provider).values).uniq
|
||||
end
|
||||
|
||||
def provider_config_options(provider = nil)
|
||||
providers = provider.present? ? [provider.to_s] : provider_options.keys
|
||||
|
||||
providers.each_with_object({}) do |provider_name, result|
|
||||
provider_configuration_options(provider_name).each do |option|
|
||||
result[option] = installation_config_name(option)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def current_provider
|
||||
provider = InstallationConfig.find_by(name: PROVIDER_CONFIG_KEY)&.value.presence
|
||||
return provider if provider_options.key?(provider)
|
||||
|
||||
Llm::Config::DEFAULT_PROVIDER
|
||||
end
|
||||
|
||||
def api_key_for(provider)
|
||||
provider_config_values(provider)[:"#{provider}_api_key"]
|
||||
end
|
||||
|
||||
def api_base_for(provider)
|
||||
api_base = provider_config_values(provider)[:"#{provider}_api_base"].presence
|
||||
return if api_base.blank?
|
||||
|
||||
normalized_api_base(provider, api_base)
|
||||
end
|
||||
|
||||
def provider_configured?(provider)
|
||||
requirements = provider_configuration_requirements(provider)
|
||||
return false if requirements.blank?
|
||||
|
||||
values = provider_config_values(provider)
|
||||
requirements.all? { |requirement| values[requirement].present? }
|
||||
end
|
||||
|
||||
def openai_provider?(provider)
|
||||
provider.to_s == Llm::Config::DEFAULT_PROVIDER
|
||||
end
|
||||
|
||||
def supports_tools_and_schema?(provider)
|
||||
openai_provider?(provider)
|
||||
end
|
||||
|
||||
def provider_config_values(provider)
|
||||
provider = provider.to_s
|
||||
provider_configuration_options(provider).each_with_object({}) do |option, values|
|
||||
value = installation_config_value(option).presence
|
||||
value = normalized_api_base(provider, value) if option == :"#{provider}_api_base" && value.present?
|
||||
values[option] = value if value.present?
|
||||
end
|
||||
end
|
||||
|
||||
def configure_provider(config, provider:, config_values:)
|
||||
options = provider_configuration_options(provider)
|
||||
config_values.each do |option, value|
|
||||
set_config_value(config, option, value) if value.present? && options.include?(option)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ruby_llm_provider_name(provider)
|
||||
RubyLLM::Provider.providers[provider.to_s.to_sym].name
|
||||
end
|
||||
|
||||
def provider_configuration_options(provider)
|
||||
RubyLLM::Provider.providers[provider.to_s.to_sym]&.configuration_options || []
|
||||
end
|
||||
|
||||
def provider_configuration_requirements(provider)
|
||||
RubyLLM::Provider.providers[provider.to_s.to_sym]&.configuration_requirements || []
|
||||
end
|
||||
|
||||
def set_config_value(config, option, value)
|
||||
setter = :"#{option}="
|
||||
config.public_send(setter, value) if config.respond_to?(setter)
|
||||
end
|
||||
|
||||
def installation_config_value(option)
|
||||
InstallationConfig.find_by(name: installation_config_name(option))&.value
|
||||
end
|
||||
|
||||
def installation_config_name(option)
|
||||
LEGACY_CONFIG_KEYS.fetch(option.to_sym) do
|
||||
"#{PROVIDER_CONFIG_PREFIX}_#{option.to_s.upcase}"
|
||||
end
|
||||
end
|
||||
|
||||
def normalized_api_base(provider, api_base)
|
||||
endpoint = api_base.chomp('/').delete_suffix('/chat/completions')
|
||||
return "#{endpoint}/v1" if openai_provider?(provider) && endpoint.exclude?('/v1')
|
||||
|
||||
endpoint
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
module Llm::ProviderModelCatalog
|
||||
def installation_model(provider: provider_for_feature(nil))
|
||||
model = InstallationConfig.find_by(name: Llm::ProviderConfig::MODEL_CONFIG_KEY)&.value.presence
|
||||
return model if model_provider?(model, provider)
|
||||
|
||||
legacy_model = InstallationConfig.find_by(name: Llm::ProviderConfig::LEGACY_OPENAI_MODEL_CONFIG_KEY)&.value.presence
|
||||
return legacy_model if provider == Llm::Config::DEFAULT_PROVIDER && model_provider?(legacy_model, provider)
|
||||
end
|
||||
|
||||
def provider_default_model_options(provider = Llm::Config.current_provider)
|
||||
models_for_provider(provider).index_with do |model_name|
|
||||
model = model_config(model_name)
|
||||
model['display_name'] || model_name
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def provider_for_feature(feature)
|
||||
return Llm::Config::DEFAULT_PROVIDER if openai_only_feature?(feature)
|
||||
|
||||
Llm::Config.current_provider
|
||||
end
|
||||
|
||||
def configured_models_for(feature, provider:)
|
||||
(features.dig(feature.to_s, 'models') || []).select do |model_name|
|
||||
supported_model?(model_name) && model_provider?(model_name, provider)
|
||||
end
|
||||
end
|
||||
|
||||
def provider_models_for(feature, provider:)
|
||||
return [] if openai_only_feature?(feature)
|
||||
return [] if provider == Llm::Config::DEFAULT_PROVIDER
|
||||
|
||||
models_for_provider(provider)
|
||||
end
|
||||
|
||||
def models_for_provider(provider)
|
||||
configured_provider_models = models.filter_map do |model_name, config|
|
||||
model_name if config['provider'] == provider.to_s && chat_model?(model_name)
|
||||
end
|
||||
|
||||
(configured_provider_models + ruby_llm_provider_models(provider)).uniq
|
||||
end
|
||||
|
||||
def ruby_llm_provider_models(provider)
|
||||
return [] if provider.to_s == Llm::Config::DEFAULT_PROVIDER
|
||||
|
||||
RubyLLM.models.by_provider(provider.to_s).chat_models.map(&:id)
|
||||
end
|
||||
|
||||
def chat_model?(model_name)
|
||||
Llm::Models::OPENAI_ONLY_FEATURES.none? { |feature| features.dig(feature, 'models')&.include?(model_name) }
|
||||
end
|
||||
|
||||
def model_provider?(model_name, provider)
|
||||
provider_for(model_name) == provider.to_s
|
||||
end
|
||||
|
||||
def openai_only_feature?(feature)
|
||||
Llm::Models::OPENAI_ONLY_FEATURES.include?(feature.to_s)
|
||||
end
|
||||
end
|
||||
@@ -66,6 +66,20 @@ RSpec.describe 'Super Admin accounts API', type: :request do
|
||||
expect(editor_select.at_css('option[value=""]').text.squish).to eq("Use default: #{default_model} (#{default_model_id})")
|
||||
expect(editor_select.css('optgroup').map { |group| group['label'] }).to include('Default routing', 'OpenAI')
|
||||
end
|
||||
|
||||
it 'includes selected RubyLLM provider models in the Captain model selectors', if: ChatwootApp.enterprise? do
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter')
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
|
||||
get "/super_admin/accounts/#{account.id}/edit"
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
document = Nokogiri::HTML(response.body)
|
||||
assistant_select = document.at_css('select[name="account[captain_models][assistant]"]')
|
||||
|
||||
expect(assistant_select.css('optgroup').map { |group| group['label'] }).to include('OpenRouter')
|
||||
expect(assistant_select.at_css('option[value="ai21/jamba-large-1.7"]')).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -61,11 +61,34 @@ RSpec.describe 'Super Admin Application Config API', type: :request do
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
|
||||
post '/super_admin/app_config?config=captain',
|
||||
params: { app_config: { CAPTAIN_ANTHROPIC_API_KEY: 'anthropic-key', CAPTAIN_GEMINI_API_KEY: 'gemini-key' } }
|
||||
params: {
|
||||
app_config: {
|
||||
CAPTAIN_LLM_PROVIDER: 'openrouter',
|
||||
CAPTAIN_LLM_MODEL: 'ai21/jamba-large-1.7',
|
||||
CAPTAIN_LLM_OPENROUTER_API_KEY: 'openrouter-key'
|
||||
}
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(GlobalConfig.get('CAPTAIN_ANTHROPIC_API_KEY')['CAPTAIN_ANTHROPIC_API_KEY']).to eq('anthropic-key')
|
||||
expect(GlobalConfig.get('CAPTAIN_GEMINI_API_KEY')['CAPTAIN_GEMINI_API_KEY']).to eq('gemini-key')
|
||||
expect(GlobalConfig.get('CAPTAIN_LLM_PROVIDER')['CAPTAIN_LLM_PROVIDER']).to eq('openrouter')
|
||||
expect(GlobalConfig.get('CAPTAIN_LLM_MODEL')['CAPTAIN_LLM_MODEL']).to eq('ai21/jamba-large-1.7')
|
||||
expect(GlobalConfig.get('CAPTAIN_LLM_OPENROUTER_API_KEY')['CAPTAIN_LLM_OPENROUTER_API_KEY']).to eq('openrouter-key')
|
||||
end
|
||||
|
||||
it 'renders provider selection and RubyLLM provider fields for Captain settings' do
|
||||
sign_in(super_admin, scope: :super_admin)
|
||||
|
||||
get '/super_admin/app_config?config=captain&provider=openrouter'
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
document = Nokogiri::HTML(response.body)
|
||||
|
||||
provider_option = document.at_css('select[name="app_config[CAPTAIN_LLM_PROVIDER]"] option[value="openrouter"]')
|
||||
expect(provider_option).to be_present
|
||||
expect(provider_option['selected']).to eq('selected')
|
||||
expect(document.at_css('select[name="app_config[CAPTAIN_LLM_MODEL]"] option[value="ai21/jamba-large-1.7"]')).to be_present
|
||||
expect(document.at_css('input[name="app_config[CAPTAIN_LLM_OPENROUTER_API_KEY]"]')).to be_present
|
||||
expect(document.at_css('input[name="app_config[CAPTAIN_ANTHROPIC_API_KEY]"]')).to be_blank
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,7 +10,7 @@ RSpec.describe Captain::ConversationCompletionService do
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(Llm::Config).to receive(:with_provider).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:with_schema).and_return(mock_chat)
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
@@ -134,7 +134,9 @@ RSpec.describe Captain::ConversationCompletionService do
|
||||
end
|
||||
|
||||
it 'uses the system API key instead of the account hook key' do
|
||||
expect(Llm::Config).to receive(:with_api_key).with('test-key', api_base: anything).and_yield(mock_context)
|
||||
expect(Llm::Config).to receive(:with_provider)
|
||||
.with(provider: 'openai', config_values: hash_including(openai_api_key: 'test-key'))
|
||||
.and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:ask).and_return(
|
||||
instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5)
|
||||
)
|
||||
@@ -145,7 +147,7 @@ RSpec.describe Captain::ConversationCompletionService do
|
||||
it 'does not fall back to the account hook key when no system key exists' do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY').update!(value: nil)
|
||||
|
||||
expect(Llm::Config).not_to receive(:with_api_key)
|
||||
expect(Llm::Config).not_to receive(:with_provider)
|
||||
|
||||
result = service.perform
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ RSpec.describe Concerns::Agentable do
|
||||
let(:mock_agents_agent) { instance_double(Agents::Agent) }
|
||||
|
||||
before do
|
||||
InstallationConfig.where(name: 'CAPTAIN_OPEN_AI_MODEL').destroy_all
|
||||
InstallationConfig.where(name: %w[CAPTAIN_OPEN_AI_MODEL CAPTAIN_LLM_MODEL]).destroy_all
|
||||
allow(Agents::Agent).to receive(:new).and_return(mock_agents_agent)
|
||||
allow(Captain::PromptRenderer).to receive(:render).and_return('rendered_template')
|
||||
end
|
||||
@@ -167,16 +167,16 @@ RSpec.describe Concerns::Agentable do
|
||||
end
|
||||
|
||||
it 'returns account override model when present' do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_MODEL', value: 'gpt-5-mini')
|
||||
account.update!(captain_models: { 'assistant' => 'gpt-5.2' })
|
||||
|
||||
expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2')
|
||||
end
|
||||
|
||||
it 'returns the installation model when account override is absent' do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_MODEL', value: 'gpt-5-mini')
|
||||
|
||||
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-nano')
|
||||
expect(dummy_instance.send(:agent_model)).to eq('gpt-5-mini')
|
||||
end
|
||||
|
||||
it 'returns the assistant feature default model when account is nil' do
|
||||
|
||||
@@ -25,14 +25,36 @@ RSpec.describe Captain::Llm::EmbeddingService, type: :service do
|
||||
|
||||
describe '#get_embedding' do
|
||||
let(:account) { create(:account) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context) }
|
||||
let(:embedding_response) { double('embedding_response', vectors: [0.1, 0.2]) } # rubocop:disable RSpec/VerifiedDoubles
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
end
|
||||
|
||||
it 'sends the installation embedding model to RubyLLM' do
|
||||
configure_embedding_model('custom-embedding-model')
|
||||
|
||||
expect(RubyLLM).to receive(:embed).with('search text', model: 'custom-embedding-model').and_return(embedding_response)
|
||||
expect(Llm::Config).to receive(:with_provider).with(provider: 'openai').and_yield(mock_context)
|
||||
expect(mock_context).to receive(:embed).with(
|
||||
'search text',
|
||||
model: 'custom-embedding-model',
|
||||
provider: 'openai',
|
||||
assume_model_exists: true
|
||||
).and_return(embedding_response)
|
||||
|
||||
expect(described_class.new(account_id: account.id).get_embedding('search text')).to eq([0.1, 0.2])
|
||||
end
|
||||
|
||||
it 'requires OpenAI configuration even when another LLM provider is selected' do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY').destroy
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter')
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_OPENROUTER_API_KEY', value: 'openrouter-key')
|
||||
|
||||
expect(Llm::Config).not_to receive(:with_provider)
|
||||
|
||||
expect { described_class.new(account_id: account.id).get_embedding('search text') }
|
||||
.to raise_error(described_class::EmbeddingsError, 'OpenAI configuration is required for embeddings.')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,28 +6,28 @@ RSpec.describe Llm::BaseAiService do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
before do
|
||||
InstallationConfig.where(name: %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL]).destroy_all
|
||||
InstallationConfig.where(name: %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_LLM_MODEL]).destroy_all
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
it 'uses the installation model when no feature is provided' do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_MODEL', value: 'gpt-4.1-nano')
|
||||
|
||||
expect(described_class.new.model).to eq('gpt-4.1-nano')
|
||||
end
|
||||
|
||||
it 'uses the account override when feature context is provided' do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_MODEL', value: 'gpt-5-mini')
|
||||
account.update!(captain_models: { 'assistant' => 'gpt-5.2' })
|
||||
|
||||
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2')
|
||||
end
|
||||
|
||||
it 'uses the installation model when feature context has no account override' do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_MODEL', value: 'gpt-5-mini')
|
||||
|
||||
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-4.1-nano')
|
||||
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5-mini')
|
||||
end
|
||||
|
||||
it 'uses the feature default when feature context has no account override or installation model' do
|
||||
|
||||
@@ -120,7 +120,7 @@ RSpec.describe Captain::BaseTaskService do
|
||||
let(:mock_response) { instance_double(RubyLLM::Message, content: 'Response', input_tokens: 10, output_tokens: 20) }
|
||||
|
||||
before do
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(Llm::Config).to receive(:with_provider).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
@@ -138,7 +138,7 @@ RSpec.describe Captain::BaseTaskService do
|
||||
end
|
||||
|
||||
it 'does not make API call' do
|
||||
expect(Llm::Config).not_to receive(:with_api_key)
|
||||
expect(Llm::Config).not_to receive(:with_provider)
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
end
|
||||
@@ -158,7 +158,7 @@ RSpec.describe Captain::BaseTaskService do
|
||||
end
|
||||
|
||||
it 'does not make API call' do
|
||||
expect(Llm::Config).not_to receive(:with_api_key)
|
||||
expect(Llm::Config).not_to receive(:with_provider)
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
end
|
||||
@@ -200,16 +200,20 @@ RSpec.describe Captain::BaseTaskService do
|
||||
end
|
||||
|
||||
it 'uses the model provider for account overrides' do
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'anthropic')
|
||||
create(:installation_config, name: 'CAPTAIN_ANTHROPIC_API_KEY', value: 'anthropic-key')
|
||||
account.update!(captain_models: { 'assistant' => 'claude-haiku-4.5' })
|
||||
|
||||
expect(Llm::Config).to receive(:with_api_key).with('anthropic-key', provider: 'anthropic', api_base: nil).and_yield(mock_context)
|
||||
expect(Llm::Config).to receive(:with_provider)
|
||||
.with(provider: 'anthropic', config_values: hash_including(anthropic_api_key: 'anthropic-key'))
|
||||
.and_yield(mock_context)
|
||||
expect(mock_context).to receive(:chat).with(model: 'claude-haiku-4.5', provider: 'anthropic', assume_model_exists: true).and_return(mock_chat)
|
||||
|
||||
service.send(:make_api_call, feature: 'assistant', messages: messages)
|
||||
end
|
||||
|
||||
it 'does not attach schemas or tools for non-OpenAI providers' do
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'anthropic')
|
||||
create(:installation_config, name: 'CAPTAIN_ANTHROPIC_API_KEY', value: 'anthropic-key')
|
||||
account.update!(captain_models: { 'assistant' => 'claude-haiku-4.5' })
|
||||
|
||||
@@ -239,7 +243,7 @@ RSpec.describe Captain::BaseTaskService do
|
||||
let(:mock_response) { instance_double(RubyLLM::Message, content: 'Response', input_tokens: 10, output_tokens: 20) }
|
||||
|
||||
before do
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(Llm::Config).to receive(:with_provider).and_yield(mock_context)
|
||||
allow(mock_response).to receive(:input_tokens).and_return(10)
|
||||
allow(mock_response).to receive(:output_tokens).and_return(20)
|
||||
end
|
||||
@@ -295,7 +299,7 @@ RSpec.describe Captain::BaseTaskService do
|
||||
let(:exception_tracker) { instance_double(ChatwootExceptionTracker) }
|
||||
|
||||
before do
|
||||
allow(Llm::Config).to receive(:with_api_key).and_raise(error)
|
||||
allow(Llm::Config).to receive(:with_provider).and_raise(error)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
|
||||
allow(exception_tracker).to receive(:capture_exception)
|
||||
end
|
||||
@@ -318,7 +322,9 @@ RSpec.describe Captain::BaseTaskService do
|
||||
it 'tracks exceptions against the system key when an account hook exists' do
|
||||
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'hook-key' })
|
||||
|
||||
expect(Llm::Config).to receive(:with_api_key).with('test-key', provider: 'openai', api_base: nil).and_raise(error)
|
||||
expect(Llm::Config).to receive(:with_provider)
|
||||
.with(provider: 'openai', config_values: hash_including(openai_api_key: 'test-key'))
|
||||
.and_raise(error)
|
||||
expect(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
|
||||
expect(exception_tracker).to receive(:capture_exception)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ RSpec.describe Captain::LabelSuggestionService do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
label1
|
||||
label2
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(Llm::Config).to receive(:with_provider).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
# Stub captain enabled check to allow specs to test base functionality
|
||||
|
||||
@@ -18,7 +18,7 @@ RSpec.describe Captain::ReplySuggestionService do
|
||||
mock_chat = instance_double(RubyLLM::Chat)
|
||||
mock_context = instance_double(RubyLLM::Context, chat: mock_chat)
|
||||
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(Llm::Config).to receive(:with_provider).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_tool).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:on_end_message).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_instructions) { |msg| captured_messages << { role: 'system', content: msg } }
|
||||
|
||||
@@ -13,7 +13,7 @@ RSpec.describe Captain::RewriteService do
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(Llm::Config).to receive(:with_provider).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
# Stub captain enabled check to allow specs to test base functionality
|
||||
|
||||
@@ -11,7 +11,7 @@ RSpec.describe Captain::SummaryService do
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(Llm::Config).to receive(:with_provider).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
# Stub captain enabled check to allow specs to test base functionality
|
||||
|
||||
@@ -15,7 +15,7 @@ RSpec.describe Integrations::LlmBaseService do
|
||||
describe '#make_api_call' do
|
||||
before do
|
||||
allow(service).to receive(:instrument_llm_call).and_yield
|
||||
allow(Llm::Config).to receive(:with_api_key).and_raise(error)
|
||||
allow(Llm::Config).to receive(:with_provider).and_raise(error)
|
||||
end
|
||||
|
||||
it 'does not track exceptions for hook key failures' do
|
||||
|
||||
@@ -8,11 +8,35 @@ RSpec.describe Llm::Config do
|
||||
expect(described_class.provider_options).to include(
|
||||
'openai' => 'OpenAI',
|
||||
'anthropic' => 'Anthropic',
|
||||
'gemini' => 'Gemini'
|
||||
'gemini' => 'Gemini',
|
||||
'openrouter' => 'OpenRouter'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.provider_config_keys' do
|
||||
it 'includes dynamic RubyLLM provider credential keys' do
|
||||
expect(described_class.provider_config_keys).to include(
|
||||
'CAPTAIN_LLM_PROVIDER',
|
||||
'CAPTAIN_LLM_MODEL',
|
||||
'CAPTAIN_LLM_OPENROUTER_API_KEY'
|
||||
)
|
||||
end
|
||||
|
||||
it 'can be scoped to a single provider' do
|
||||
expect(described_class.provider_config_keys('openrouter')).to include('CAPTAIN_LLM_OPENROUTER_API_KEY')
|
||||
expect(described_class.provider_config_keys('openrouter')).not_to include('CAPTAIN_ANTHROPIC_API_KEY')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.provider_configured?' do
|
||||
it 'uses dynamic provider requirements' do
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_OPENROUTER_API_KEY', value: 'openrouter-key')
|
||||
|
||||
expect(described_class.provider_configured?('openrouter')).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '.api_base_for' do
|
||||
it 'normalizes OpenAI-compatible endpoints to the v1 base' do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://proxy.example.com/chat/completions')
|
||||
|
||||
@@ -48,6 +48,41 @@ RSpec.describe Llm::Models do
|
||||
|
||||
expect(described_class.models_for('assistant')).not_to include('claude-haiku-4.5')
|
||||
end
|
||||
|
||||
it 'adds selected RubyLLM provider chat models for chat features' do
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter')
|
||||
|
||||
expect(described_class.models_for('assistant')).to include('ai21/jamba-large-1.7')
|
||||
expect(described_class.models_for('assistant')).not_to include('gpt-4.1')
|
||||
end
|
||||
|
||||
it 'does not add selected chat-provider models to OpenAI-only features' do
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter')
|
||||
|
||||
expect(described_class.models_for('help_center_search')).not_to include('ai21/jamba-large-1.7')
|
||||
expect(described_class.models_for('help_center_search')).to include('text-embedding-3-small')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.default_model_for' do
|
||||
it 'uses the installation model when it is valid for the feature and active provider' do
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_MODEL', value: 'gpt-5-mini')
|
||||
|
||||
expect(described_class.default_model_for('assistant')).to eq('gpt-5-mini')
|
||||
end
|
||||
|
||||
it 'ignores installation models from another provider' do
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter')
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_MODEL', value: 'gpt-5-mini')
|
||||
|
||||
expect(described_class.default_model_for('assistant')).to eq('ai21/jamba-large-1.7')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.provider_for' do
|
||||
it 'uses RubyLLM model metadata for provider models outside llm.yml' do
|
||||
expect(described_class.provider_for('ai21/jamba-large-1.7')).to eq('openrouter')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.feature_config' do
|
||||
|
||||
@@ -386,6 +386,21 @@ RSpec.describe Account do
|
||||
expect(account).to be_valid
|
||||
end
|
||||
|
||||
it 'accepts models from the selected RubyLLM provider' do
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter')
|
||||
account.captain_models = { 'assistant' => 'ai21/jamba-large-1.7' }
|
||||
|
||||
expect(account).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects models outside the selected LLM provider' do
|
||||
create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter')
|
||||
account.captain_models = { 'assistant' => 'gpt-5.2' }
|
||||
|
||||
expect(account).not_to be_valid
|
||||
expect(account.errors[:captain_models].first).to include('not a valid model for assistant')
|
||||
end
|
||||
|
||||
it 'rejects unknown feature keys' do
|
||||
account.captain_models = { 'unknown_feature' => 'gpt-4.1' }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user