Compare commits

...
37 changed files with 796 additions and 149 deletions
@@ -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
@@ -50,7 +51,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT]
'captain' => captain_config_options
}
@allowed_configs = mapping.fetch(
@@ -71,7 +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
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
+6
View File
@@ -19,6 +19,12 @@ 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
CAPTAIN_GEMINI_API_BASE
].freeze
RESTART_REQUIRED_CONFIG_KEYS = (CAPTAIN_LLM_CONFIG_KEYS + %w[
@@ -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 %>
+30 -1
View File
@@ -189,12 +189,41 @@
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.'
locked: false
type: secret
- name: CAPTAIN_ANTHROPIC_API_BASE
display_title: 'Anthropic API Base (optional)'
description: 'The Anthropic endpoint configured for use in Captain AI. Defaults to RubyLLM provider settings.'
locked: false
- name: CAPTAIN_GEMINI_API_KEY
display_title: 'Gemini API Key'
description: 'The API key used to authenticate requests to Gemini models for Captain AI.'
locked: false
type: secret
- name: CAPTAIN_GEMINI_API_BASE
display_title: 'Gemini API Base (optional)'
description: 'The Gemini endpoint configured for use in Captain AI. Defaults to RubyLLM provider settings.'
locked: false
- name: CAPTAIN_EMBEDDING_MODEL
display_title: 'Embedding Model (optional)'
description: 'The embedding model configured for use in Captain AI. Default: text-embedding-3-small'
+2
View File
@@ -577,6 +577,7 @@ en:
captain_model_overrides:
form:
helper_text: 'Leave a model blank to use the YAML default for that AI feature.'
default_group: 'Default routing'
use_default: 'Use default: %{model} (%{model_id})'
show:
summary: 'View model routing'
@@ -591,6 +592,7 @@ en:
copilot: 'Copilot'
label_suggestion: 'Label suggestion'
document_faq_generation: 'Document FAQ generation'
pdf_faq_generation: 'PDF FAQ generation'
help_center_article_generation: 'Help center article generation'
onboarding_content_generation: 'Onboarding content generation'
help_center_query_translation: 'Help center query translation'
@@ -41,10 +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
super + %w[
CAPTAIN_EMBEDDING_MODEL
CAPTAIN_FIRECRAWL_API_KEY
]
@@ -33,8 +33,14 @@ class CaptainModelOverridesField < Administrate::Field::Base
end
def model_options(feature_key)
Llm::Models.feature_config(feature_key)[:models].map do |model|
[model[:display_name] || model[:id], model[:id]]
models_by_provider = Llm::Models.feature_config(feature_key)[:models].group_by { |model| model[:provider] }
grouped_models = models_by_provider.transform_keys do |provider|
provider_label(provider)
end
grouped_models.transform_values do |models|
models.map { |model| [model[:display_name] || model[:id], model[:id]] }
end
end
+1 -7
View File
@@ -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}"
+15 -6
View File
@@ -6,7 +6,7 @@ class Llm::BaseAiService
DEFAULT_MODEL = Llm::Config::DEFAULT_MODEL
DEFAULT_TEMPERATURE = 1.0
attr_reader :model, :temperature
attr_reader :model, :provider, :temperature
def initialize(feature: nil, account: nil, fallback_model: nil)
@llm_feature = feature
@@ -19,7 +19,8 @@ class Llm::BaseAiService
end
def chat(model: @model, temperature: @temperature)
RubyLLM.chat(model: model).with_temperature(temperature)
chat = RubyLLM.chat(model: model, provider: provider_for_model(model), assume_model_exists: true).with_temperature(temperature)
Llm::ProviderChat.new(chat, provider: provider_for_model(model))
end
private
@@ -34,9 +35,13 @@ class Llm::BaseAiService
def setup_model
route = feature_route
return @model = route[:model] if account_override_route?(route)
if account_override_route?(route)
@model = route[:model]
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
def feature_route
@@ -49,8 +54,12 @@ class Llm::BaseAiService
route&.dig(:source) == :account_override
end
def installation_model
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
def setup_provider(route)
@provider = provider_for_model(@model, route&.dig(:provider))
end
def provider_for_model(model, fallback_provider = Llm::Config::DEFAULT_PROVIDER)
Llm::Models.provider_for(model) || fallback_provider || Llm::Config::DEFAULT_PROVIDER
end
def setup_temperature
@@ -15,8 +15,10 @@
<%= select_tag(
"account[captain_models][#{feature[:key]}]",
options_for_select(
[[t('super_admin.captain_model_overrides.form.use_default', model: feature[:default_model], model_id: feature[:default_model_id]), '']] + feature[:options],
grouped_options_for_select(
{ t('super_admin.captain_model_overrides.form.default_group') => [
[t('super_admin.captain_model_overrides.form.use_default', model: feature[:default_model], model_id: feature[:default_model_id]), '']
] }.merge(feature[:options]),
feature[:selected_override]
),
class: 'block w-full rounded-md border-slate-300 text-sm'
@@ -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?
+46 -53
View File
@@ -31,24 +31,24 @@ class Captain::BaseTaskService
@conversation ||= account.conversations.find_by(display_id: conversation_display_id)
end
def api_base
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
endpoint = endpoint.chomp('/')
"#{endpoint}/v1"
end
def api_base = Llm::Config.api_base_for(llm_provider)
def make_api_call(messages:, model: nil, feature: nil, schema: nil, tools: [])
llm_route = resolved_llm_route(model: model, feature: feature)
# Community edition prerequisite checks
# Enterprise module handles these with more specific error messages (cloud vs self-hosted)
return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
return { error: I18n.t('captain.api_key_missing'), error_code: 401 } unless api_key_configured?
return { error: I18n.t('captain.api_key_missing'), error_code: 401 } unless api_key_configured?(llm_route[:provider])
model = resolved_model(model: model, feature: feature)
instrumentation_params = build_instrumentation_params(model, messages)
instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call
@llm_provider = llm_route[:provider]
model = llm_route[:model]
request_tools = Llm::Config.supports_tools_and_schema?(llm_route[:provider]) ? tools : []
instrumentation_params = build_instrumentation_params(model, messages, llm_route[:provider])
instrumentation_method = request_tools.any? ? :instrument_tool_session : :instrument_llm_call
response = send(instrumentation_method, instrumentation_params) do
execute_ruby_llm_request(model: model, messages: messages, schema: schema, tools: tools)
execute_ruby_llm_request(llm_route: llm_route, messages: messages, schema: schema, tools: request_tools)
end
return response unless build_follow_up_context? && response[:message].present?
@@ -56,20 +56,28 @@ class Captain::BaseTaskService
response.merge(follow_up_context: build_follow_up_context(messages, response))
end
def resolved_model(model:, feature:)
return model if feature.blank?
def resolved_llm_route(model:, feature:)
return explicit_model_route(model) if feature.blank?
route = Llm::FeatureRouter.resolve(feature: feature, account: account)
return model if model.present? && route[:source] == :default
resolved_model = model.present? && route[:source] == :default ? model : route[:model]
route[:model]
route.merge(model: resolved_model, provider: provider_for_model(resolved_model, route[:provider]))
end
def execute_ruby_llm_request(model:, messages:, schema: nil, tools: [])
credential = llm_credential
def explicit_model_route(model)
resolved_model = model.presence || GPT_MODEL
{ model: resolved_model, provider: provider_for_model(resolved_model), source: :explicit }
end
Llm::Config.with_api_key(credential[:api_key], api_base: api_base) do |context|
chat = build_chat(context, model: model, messages: messages, schema: schema, tools: tools)
def provider_for_model(model, fallback_provider = Llm::Config::DEFAULT_PROVIDER) = Llm::Models.provider_for(model) || fallback_provider
def execute_ruby_llm_request(llm_route:, messages:, schema: nil, tools: [])
provider = llm_route[:provider]
credential = llm_credential(provider)
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' }
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
@@ -82,15 +90,17 @@ class Captain::BaseTaskService
{ error: e.message, request_messages: messages }
end
def build_chat(context, model:, messages:, schema: nil, tools: [])
chat = context.chat(model: model)
def build_chat(context, llm_route:, messages:, schema: nil, tools: [])
model = llm_route[:model]
provider = llm_route[:provider]
chat = Llm::ProviderChat.new(context.chat(model: model, provider: provider, assume_model_exists: true), provider: provider)
system_msg = messages.find { |m| m[:role] == 'system' }
chat.with_instructions(system_msg[:content]) if system_msg
chat.with_schema(schema) if schema
if tools.any?
tools.each { |tool| chat = chat.with_tool(tool) }
chat.on_end_message { |message| record_generation(chat, message, model) }
chat.on_end_message { |message| record_generation(chat, message, model, provider) }
end
chat
@@ -116,13 +126,14 @@ class Captain::BaseTaskService
}
end
def build_instrumentation_params(model, messages)
def build_instrumentation_params(model, messages, provider)
{
span_name: "llm.#{event_name}",
account_id: account.id,
conversation_id: conversation&.display_id,
feature_name: event_name,
model: model,
provider: provider,
messages: messages,
temperature: nil,
metadata: instrumentation_metadata
@@ -155,9 +166,7 @@ class Captain::BaseTaskService
messages
end
def captain_tasks_enabled?
account.feature_enabled?('captain_tasks')
end
def captain_tasks_enabled? = account.feature_enabled?('captain_tasks')
# Extension point consulted by the Enterprise quota wrapper. Subclasses
# whose calls should not consume captain_responses should override this to
@@ -168,43 +177,27 @@ class Captain::BaseTaskService
llm_credential&.dig(:source) != :hook
end
def api_key_configured?
llm_credential.present?
def api_key_configured?(provider = llm_provider) = llm_credential(provider).present?
def api_key = llm_credential&.dig(:api_key)
def llm_provider = @llm_provider || Llm::Config::DEFAULT_PROVIDER
def llm_credential(provider = llm_provider)
@llm_credentials ||= {}
@llm_credentials[provider.to_s] ||= Llm::CredentialResolver.new(provider: provider, openai_hook: resolved_openai_hook(provider)).resolve
end
def api_key
llm_credential&.dig(:api_key)
end
def resolved_openai_hook(provider) = use_account_openai_hook? && Llm::Config.openai_provider?(provider) ? openai_hook : nil
def llm_credential
@llm_credential ||= if use_account_openai_hook?
hook_llm_credential || system_llm_credential
else
system_llm_credential
end
end
def use_account_openai_hook? = false
def use_account_openai_hook?
false
end
def hook_llm_credential
key = openai_hook&.settings&.dig('api_key').presence
{ api_key: key, source: :hook } if key
end
def system_llm_credential
{ api_key: system_api_key, source: :system } if system_api_key.present?
end
def system_llm_credential(provider = llm_provider) = Llm::CredentialResolver.new(provider: provider).resolve
def openai_hook
@openai_hook ||= account.hooks.find_by(app_id: 'openai', status: 'enabled')
end
def system_api_key
@system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
end
def exception_tracking_account
account
end
+2 -2
View File
@@ -38,13 +38,13 @@ module Captain::ToolInstrumentation
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
end
def record_generation(chat, message, model)
def record_generation(chat, message, model, provider = Llm::Config::DEFAULT_PROVIDER)
return unless ChatwootApp.otel_enabled?
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
tracer.in_span("llm.#{event_name}.generation") do |span|
apply_current_langfuse_attributes(span)
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model)
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens)
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens)
+15 -6
View File
@@ -84,13 +84,12 @@ class Integrations::LlmBaseService
end
def api_base
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
endpoint = endpoint.chomp('/')
"#{endpoint}/v1"
Llm::Config.api_base_for(llm_provider)
end
def make_api_call(body)
parsed_body = JSON.parse(body)
@llm_provider = provider_for_model(parsed_body['model'])
instrumentation_params = build_instrumentation_params(parsed_body)
instrument_llm_call(instrumentation_params) do
@@ -102,9 +101,10 @@ class Integrations::LlmBaseService
messages = parsed_body['messages']
model = parsed_body['model']
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], api_base: api_base) do |context|
chat = context.chat(model: model)
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
rescue StandardError => e
@@ -161,13 +161,22 @@ class Integrations::LlmBaseService
conversation_id: conversation&.display_id,
feature_name: event_name,
model: parsed_body['model'],
provider: llm_provider,
messages: parsed_body['messages'],
temperature: parsed_body['temperature']
}
end
def llm_credential
@llm_credential ||= { api_key: hook.settings['api_key'], source: :hook }
@llm_credential ||= Llm::CredentialResolver.new(provider: llm_provider, openai_hook: hook).resolve
end
def llm_provider
@llm_provider || Llm::Config::DEFAULT_PROVIDER
end
def provider_for_model(model)
Llm::Models.provider_for(model) || Llm::Config::DEFAULT_PROVIDER
end
def exception_tracking_account
@@ -35,7 +35,7 @@ module Integrations::LlmInstrumentationHelpers
end
def set_request_attributes(span, params)
provider = determine_provider(params[:model])
provider = params[:provider] || determine_provider(params[:model])
span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
+24 -19
View File
@@ -1,12 +1,14 @@
require 'ruby_llm'
require_relative 'provider_config'
module Llm::Config
extend Llm::ProviderConfig
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
DEFAULT_PROVIDER = 'openai'.freeze
class << self
def initialized?
@initialized ||= false
end
def initialized? = @initialized ||= false
def initialize!
return if @initialized
@@ -15,15 +17,25 @@ module Llm::Config
@initialized = true
end
def reset!
@initialized = false
end
def reset! = @initialized = false
def with_api_key(api_key, api_base: nil)
def with_api_key(api_key, provider: DEFAULT_PROVIDER, api_base: nil, config_values: nil)
initialize!
context = RubyLLM.context do |config|
config.openai_api_key = api_key
config.openai_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 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
yield context
@@ -33,19 +45,12 @@ module Llm::Config
def configure_ruby_llm
RubyLLM.configure do |config|
config.openai_api_key = system_api_key if system_api_key.present?
config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present?
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 system_api_key
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
end
def openai_endpoint
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value
end
end
end
+28
View File
@@ -0,0 +1,28 @@
class Llm::CredentialResolver
def initialize(provider:, openai_hook: nil)
@provider = provider.to_s
@openai_hook = openai_hook
end
def resolve
hook_llm_credential || system_llm_credential
end
private
attr_reader :provider, :openai_hook
def hook_llm_credential
return unless Llm::Config.openai_provider?(provider)
key = openai_hook&.settings&.dig('api_key').presence
{ api_key: key, config_values: { openai_api_key: key }, provider: provider, source: :hook } if key
end
def system_llm_credential
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
+58 -10
View File
@@ -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,31 +20,52 @@ module Llm::Models
end
def default_model_for(feature)
features.dig(feature.to_s, 'default')
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') || []
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)
providers.key?(provider.to_s) && Llm::Config.ruby_llm_provider_supported?(provider)
end
def supported_model?(model_name)
config = model_config(model_name)
return false unless config
supported_provider?(config['provider'])
end
def feature_config(feature_key)
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,
@@ -46,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
+37
View File
@@ -0,0 +1,37 @@
require 'delegate'
class Llm::ProviderChat < SimpleDelegator
def initialize(chat, provider:)
@provider = provider.to_s
super(chat)
end
def with_schema(schema)
return self unless supports_tools_and_schema?
__setobj__(__getobj__.with_schema(schema))
self
end
def with_tool(tool)
return self unless supports_tools_and_schema?
__setobj__(__getobj__.with_tool(tool))
self
end
def with_params(**params)
filtered_params = params.dup
filtered_params.delete(:response_format) unless supports_tools_and_schema?
return self if filtered_params.blank?
__setobj__(__getobj__.with_params(**filtered_params))
self
end
private
def supports_tools_and_schema?
Llm::Config.supports_tools_and_schema?(@provider)
end
end
+127
View File
@@ -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
+63
View File
@@ -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
@@ -64,6 +64,21 @@ RSpec.describe 'Super Admin accounts API', type: :request do
default_model = Llm::Models.model_config(default_model_id)['display_name']
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
@@ -56,6 +56,40 @@ RSpec.describe 'Super Admin Application Config API', type: :request do
expect(flash[:alert]).to be_blank
expect(flash[:notice]).to be_blank
end
it 'allows Captain provider credentials to be configured' do
sign_in(super_admin, scope: :super_admin)
post '/super_admin/app_config?config=captain',
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_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
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
+40 -11
View File
@@ -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
@@ -171,22 +171,22 @@ RSpec.describe Captain::BaseTaskService do
it 'uses the resolved feature model for the request and instrumentation' do
account.update!(captain_models: { 'editor' => 'gpt-4.1' })
expect(mock_context).to receive(:chat).with(model: 'gpt-4.1').and_return(mock_chat)
expect(mock_context).to receive(:chat).with(model: 'gpt-4.1', provider: 'openai', assume_model_exists: true).and_return(mock_chat)
expect(service).to receive(:instrument_llm_call).with(
hash_including(model: 'gpt-4.1', feature_name: 'test_event')
hash_including(model: 'gpt-4.1', provider: 'openai', feature_name: 'test_event')
).and_call_original
service.send(:make_api_call, feature: 'editor', messages: messages)
end
it 'uses the supplied model as a feature fallback when there is no account override' do
expect(mock_context).to receive(:chat).with(model: 'gpt-5.2').and_return(mock_chat)
expect(mock_context).to receive(:chat).with(model: 'gpt-5.2', provider: 'openai', assume_model_exists: true).and_return(mock_chat)
service.send(:make_api_call, feature: 'document_faq_generation', model: 'gpt-5.2', messages: messages)
end
it 'uses the help center article generation feature default' do
expect(mock_context).to receive(:chat).with(model: 'gpt-5.2').and_return(mock_chat)
expect(mock_context).to receive(:chat).with(model: 'gpt-5.2', provider: 'openai', assume_model_exists: true).and_return(mock_chat)
service.send(:make_api_call, feature: 'help_center_article_generation', messages: messages)
end
@@ -194,11 +194,38 @@ RSpec.describe Captain::BaseTaskService do
it 'prefers account overrides over supplied feature fallback models' do
account.update!(captain_models: { 'help_center_article_generation' => 'gpt-4.1' })
expect(mock_context).to receive(:chat).with(model: 'gpt-4.1').and_return(mock_chat)
expect(mock_context).to receive(:chat).with(model: 'gpt-4.1', provider: 'openai', assume_model_exists: true).and_return(mock_chat)
service.send(:make_api_call, feature: 'help_center_article_generation', model: 'gpt-5.2', messages: messages)
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_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' })
expect(mock_context).to receive(:chat).with(model: 'claude-haiku-4.5', provider: 'anthropic', assume_model_exists: true).and_return(mock_chat)
expect(mock_chat).not_to receive(:with_schema)
expect(mock_chat).not_to receive(:with_tool)
expect(service).not_to receive(:instrument_tool_session)
expect(service).to receive(:instrument_llm_call).and_call_original
service.send(:make_api_call, feature: 'assistant', messages: messages, schema: Class.new, tools: [Class.new])
end
it 'returns formatted response with tokens' do
result = service.send(:make_api_call, model: model, messages: messages)
@@ -216,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
@@ -272,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
@@ -295,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', api_base: anything).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 } }
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+60
View File
@@ -0,0 +1,60 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Llm::Config do
describe '.provider_options' do
it 'returns configured providers supported by RubyLLM' do
expect(described_class.provider_options).to include(
'openai' => 'OpenAI',
'anthropic' => 'Anthropic',
'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')
expect(described_class.api_base_for('openai')).to eq('https://proxy.example.com/v1')
end
it 'keeps non-OpenAI provider endpoints unchanged except trailing slashes' do
create(:installation_config, name: 'CAPTAIN_ANTHROPIC_API_BASE', value: 'https://anthropic.example.com/')
expect(described_class.api_base_for('anthropic')).to eq('https://anthropic.example.com')
end
end
describe '.supports_tools_and_schema?' do
it 'allows tool and schema configuration only for OpenAI' do
expect(described_class.supports_tools_and_schema?('openai')).to be true
expect(described_class.supports_tools_and_schema?('anthropic')).to be false
end
end
end
+45
View File
@@ -40,6 +40,51 @@ RSpec.describe Llm::Models do
end
end
describe '.models_for' do
it 'filters out models whose provider is not supported by RubyLLM' do
allow(Llm::Config).to receive(:ruby_llm_provider_supported?) do |provider|
provider.to_s != 'anthropic'
end
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
it 'returns model metadata for a feature' do
config = described_class.feature_config('editor')
+15
View File
@@ -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' }