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| @installation_configs = ConfigLoader.new.general_configs.each_with_object({}) do |config_hash, result|
result[config_hash['name']] = config_hash.except('name') result[config_hash['name']] = config_hash.except('name')
end end
populate_captain_config_metadata if @config == 'captain'
end end
def create def create
@@ -20,7 +21,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
next unless @allowed_configs.include?(key) next unless @allowed_configs.include?(key)
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false) 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 errors.concat(i.errors.full_messages) unless i.save
end 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], 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], '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], '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( @allowed_configs = mapping.fetch(
@@ -71,7 +72,62 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end end
def restart_required_config_saved? 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
end end
+6
View File
@@ -19,6 +19,12 @@ class InstallationConfig < ApplicationRecord
CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_API_KEY
CAPTAIN_OPEN_AI_ENDPOINT CAPTAIN_OPEN_AI_ENDPOINT
CAPTAIN_OPEN_AI_MODEL 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 ].freeze
RESTART_REQUIRED_CONFIG_KEYS = (CAPTAIN_LLM_CONFIG_KEYS + %w[ 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> </script>
<% end %> <% end %>
+30 -1
View File
@@ -189,12 +189,41 @@
type: secret type: secret
- name: CAPTAIN_OPEN_AI_MODEL - name: CAPTAIN_OPEN_AI_MODEL
display_title: 'OpenAI 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 locked: false
- name: CAPTAIN_OPEN_AI_ENDPOINT - name: CAPTAIN_OPEN_AI_ENDPOINT
display_title: 'OpenAI API Endpoint (optional)' display_title: 'OpenAI API Endpoint (optional)'
description: 'The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/' description: 'The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/'
locked: false 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 - name: CAPTAIN_EMBEDDING_MODEL
display_title: 'Embedding Model (optional)' display_title: 'Embedding Model (optional)'
description: 'The embedding model configured for use in Captain AI. Default: text-embedding-3-small' 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: captain_model_overrides:
form: form:
helper_text: 'Leave a model blank to use the YAML default for that AI feature.' 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})' use_default: 'Use default: %{model} (%{model_id})'
show: show:
summary: 'View model routing' summary: 'View model routing'
@@ -591,6 +592,7 @@ en:
copilot: 'Copilot' copilot: 'Copilot'
label_suggestion: 'Label suggestion' label_suggestion: 'Label suggestion'
document_faq_generation: 'Document FAQ generation' document_faq_generation: 'Document FAQ generation'
pdf_faq_generation: 'PDF FAQ generation'
help_center_article_generation: 'Help center article generation' help_center_article_generation: 'Help center article generation'
onboarding_content_generation: 'Onboarding content generation' onboarding_content_generation: 'Onboarding content generation'
help_center_query_translation: 'Help center query translation' help_center_query_translation: 'Help center query translation'
@@ -41,10 +41,7 @@ module Enterprise::SuperAdmin::AppConfigsController
end end
def captain_config_options def captain_config_options
%w[ super + %w[
CAPTAIN_OPEN_AI_API_KEY
CAPTAIN_OPEN_AI_MODEL
CAPTAIN_OPEN_AI_ENDPOINT
CAPTAIN_EMBEDDING_MODEL CAPTAIN_EMBEDDING_MODEL
CAPTAIN_FIRECRAWL_API_KEY CAPTAIN_FIRECRAWL_API_KEY
] ]
@@ -33,8 +33,14 @@ class CaptainModelOverridesField < Administrate::Field::Base
end end
def model_options(feature_key) def model_options(feature_key)
Llm::Models.feature_config(feature_key)[:models].map do |model| models_by_provider = Llm::Models.feature_config(feature_key)[:models].group_by { |model| model[:provider] }
[model[:display_name] || model[:id], model[:id]]
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
end end
+1 -7
View File
@@ -44,13 +44,7 @@ module Concerns::Agentable
def agent_model def agent_model
route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account) route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account)
return route[:model] if route[:source] == :account_override route[:model]
installation_model.presence || route[:model]
end
def installation_model
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
end end
def agent_response_schema def agent_response_schema
@@ -17,7 +17,11 @@ class Captain::Llm::EmbeddingService
return [] if content.blank? return [] if content.blank?
instrument_embedding_call(instrumentation_params(content, model)) do 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 end
rescue RubyLLM::Error => e rescue RubyLLM::Error => e
Rails.logger.error "Embedding API Error: #{e.message}" 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_MODEL = Llm::Config::DEFAULT_MODEL
DEFAULT_TEMPERATURE = 1.0 DEFAULT_TEMPERATURE = 1.0
attr_reader :model, :temperature attr_reader :model, :provider, :temperature
def initialize(feature: nil, account: nil, fallback_model: nil) def initialize(feature: nil, account: nil, fallback_model: nil)
@llm_feature = feature @llm_feature = feature
@@ -19,7 +19,8 @@ class Llm::BaseAiService
end end
def chat(model: @model, temperature: @temperature) 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 end
private private
@@ -34,9 +35,13 @@ class Llm::BaseAiService
def setup_model def setup_model
route = feature_route 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 end
def feature_route def feature_route
@@ -49,8 +54,12 @@ class Llm::BaseAiService
route&.dig(:source) == :account_override route&.dig(:source) == :account_override
end end
def installation_model def setup_provider(route)
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value @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 end
def setup_temperature def setup_temperature
@@ -15,8 +15,10 @@
<%= select_tag( <%= select_tag(
"account[captain_models][#{feature[:key]}]", "account[captain_models][#{feature[:key]}]",
options_for_select( grouped_options_for_select(
[[t('super_admin.captain_model_overrides.form.use_default', model: feature[:default_model], model_id: feature[:default_model_id]), '']] + feature[:options], { 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] feature[:selected_override]
), ),
class: 'block w-full rounded-md border-slate-300 text-sm' 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? return default_incomplete_response('No messages found') if content.blank?
response = make_api_call( 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: [ messages: [
{ role: 'system', content: prompt_from_file('conversation_completion') }, { role: 'system', content: prompt_from_file('conversation_completion') },
{ role: 'user', content: content } { role: 'user', content: content }
@@ -58,8 +58,9 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
# This is an internal operational evaluation, not a customer-triggered feature, # This is an internal operational evaluation, not a customer-triggered feature,
# so it should always use the installation key. # so it should always use the installation key.
def llm_credential def llm_credential(provider = llm_provider)
@llm_credential ||= system_llm_credential @llm_credentials ||= {}
@llm_credentials[provider.to_s] ||= system_llm_credential(provider)
end end
def counts_toward_usage? 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) @conversation ||= account.conversations.find_by(display_id: conversation_display_id)
end end
def api_base def api_base = Llm::Config.api_base_for(llm_provider)
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
endpoint = endpoint.chomp('/')
"#{endpoint}/v1"
end
def make_api_call(messages:, model: nil, feature: nil, schema: nil, tools: []) 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 # Community edition prerequisite checks
# Enterprise module handles these with more specific error messages (cloud vs self-hosted) # 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.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) @llm_provider = llm_route[:provider]
instrumentation_params = build_instrumentation_params(model, messages) model = llm_route[:model]
instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call 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 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 end
return response unless build_follow_up_context? && response[:message].present? 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)) response.merge(follow_up_context: build_follow_up_context(messages, response))
end end
def resolved_model(model:, feature:) def resolved_llm_route(model:, feature:)
return model if feature.blank? return explicit_model_route(model) if feature.blank?
route = Llm::FeatureRouter.resolve(feature: feature, account: account) 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 end
def execute_ruby_llm_request(model:, messages:, schema: nil, tools: []) def explicit_model_route(model)
credential = llm_credential 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| def provider_for_model(model, fallback_provider = Llm::Config::DEFAULT_PROVIDER) = Llm::Models.provider_for(model) || fallback_provider
chat = build_chat(context, model: model, messages: messages, schema: schema, tools: tools)
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' } conversation_messages = messages.reject { |m| m[:role] == 'system' }
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty? 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 } { error: e.message, request_messages: messages }
end end
def build_chat(context, model:, messages:, schema: nil, tools: []) def build_chat(context, llm_route:, messages:, schema: nil, tools: [])
chat = context.chat(model: model) 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' } system_msg = messages.find { |m| m[:role] == 'system' }
chat.with_instructions(system_msg[:content]) if system_msg chat.with_instructions(system_msg[:content]) if system_msg
chat.with_schema(schema) if schema chat.with_schema(schema) if schema
if tools.any? if tools.any?
tools.each { |tool| chat = chat.with_tool(tool) } 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 end
chat chat
@@ -116,13 +126,14 @@ class Captain::BaseTaskService
} }
end end
def build_instrumentation_params(model, messages) def build_instrumentation_params(model, messages, provider)
{ {
span_name: "llm.#{event_name}", span_name: "llm.#{event_name}",
account_id: account.id, account_id: account.id,
conversation_id: conversation&.display_id, conversation_id: conversation&.display_id,
feature_name: event_name, feature_name: event_name,
model: model, model: model,
provider: provider,
messages: messages, messages: messages,
temperature: nil, temperature: nil,
metadata: instrumentation_metadata metadata: instrumentation_metadata
@@ -155,9 +166,7 @@ class Captain::BaseTaskService
messages messages
end end
def captain_tasks_enabled? def captain_tasks_enabled? = account.feature_enabled?('captain_tasks')
account.feature_enabled?('captain_tasks')
end
# Extension point consulted by the Enterprise quota wrapper. Subclasses # Extension point consulted by the Enterprise quota wrapper. Subclasses
# whose calls should not consume captain_responses should override this to # whose calls should not consume captain_responses should override this to
@@ -168,43 +177,27 @@ class Captain::BaseTaskService
llm_credential&.dig(:source) != :hook llm_credential&.dig(:source) != :hook
end end
def api_key_configured? def api_key_configured?(provider = llm_provider) = llm_credential(provider).present?
llm_credential.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 end
def api_key def resolved_openai_hook(provider) = use_account_openai_hook? && Llm::Config.openai_provider?(provider) ? openai_hook : nil
llm_credential&.dig(:api_key)
end
def llm_credential def use_account_openai_hook? = false
@llm_credential ||= if use_account_openai_hook?
hook_llm_credential || system_llm_credential
else
system_llm_credential
end
end
def use_account_openai_hook? def system_llm_credential(provider = llm_provider) = Llm::CredentialResolver.new(provider: provider).resolve
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 openai_hook def openai_hook
@openai_hook ||= account.hooks.find_by(app_id: 'openai', status: 'enabled') @openai_hook ||= account.hooks.find_by(app_id: 'openai', status: 'enabled')
end end
def system_api_key
@system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
end
def exception_tracking_account def exception_tracking_account
account account
end end
+2 -2
View File
@@ -38,13 +38,13 @@ module Captain::ToolInstrumentation
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000)) span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
end 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 ChatwootApp.otel_enabled?
return unless message.respond_to?(:role) && message.role.to_s == 'assistant' return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
tracer.in_span("llm.#{event_name}.generation") do |span| tracer.in_span("llm.#{event_name}.generation") do |span|
apply_current_langfuse_attributes(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_REQUEST_MODEL, model)
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens) 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) 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 end
def api_base def api_base
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/' Llm::Config.api_base_for(llm_provider)
endpoint = endpoint.chomp('/')
"#{endpoint}/v1"
end end
def make_api_call(body) def make_api_call(body)
parsed_body = JSON.parse(body) parsed_body = JSON.parse(body)
@llm_provider = provider_for_model(parsed_body['model'])
instrumentation_params = build_instrumentation_params(parsed_body) instrumentation_params = build_instrumentation_params(parsed_body)
instrument_llm_call(instrumentation_params) do instrument_llm_call(instrumentation_params) do
@@ -102,9 +101,10 @@ class Integrations::LlmBaseService
messages = parsed_body['messages'] messages = parsed_body['messages']
model = parsed_body['model'] model = parsed_body['model']
credential = llm_credential 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| Llm::Config.with_provider(provider: llm_provider, config_values: credential[:config_values]) do |context|
chat = context.chat(model: model) chat = Llm::ProviderChat.new(context.chat(model: model, provider: llm_provider, assume_model_exists: true), provider: llm_provider)
setup_chat_with_messages(chat, messages) setup_chat_with_messages(chat, messages)
end end
rescue StandardError => e rescue StandardError => e
@@ -161,13 +161,22 @@ class Integrations::LlmBaseService
conversation_id: conversation&.display_id, conversation_id: conversation&.display_id,
feature_name: event_name, feature_name: event_name,
model: parsed_body['model'], model: parsed_body['model'],
provider: llm_provider,
messages: parsed_body['messages'], messages: parsed_body['messages'],
temperature: parsed_body['temperature'] temperature: parsed_body['temperature']
} }
end end
def llm_credential 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 end
def exception_tracking_account def exception_tracking_account
@@ -35,7 +35,7 @@ module Integrations::LlmInstrumentationHelpers
end end
def set_request_attributes(span, params) 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_PROVIDER, provider)
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model]) span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature] 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 'ruby_llm'
require_relative 'provider_config'
module Llm::Config module Llm::Config
extend Llm::ProviderConfig
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
DEFAULT_PROVIDER = 'openai'.freeze
class << self class << self
def initialized? def initialized? = @initialized ||= false
@initialized ||= false
end
def initialize! def initialize!
return if @initialized return if @initialized
@@ -15,15 +17,25 @@ module Llm::Config
@initialized = true @initialized = true
end end
def reset! def reset! = @initialized = false
@initialized = false
end
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! initialize!
context = RubyLLM.context do |config| context = RubyLLM.context do |config|
config.openai_api_key = api_key values = config_values || provider_config_values(provider).merge(
config.openai_api_base = api_base :"#{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 end
yield context yield context
@@ -33,19 +45,12 @@ module Llm::Config
def configure_ruby_llm def configure_ruby_llm
RubyLLM.configure do |config| RubyLLM.configure do |config|
config.openai_api_key = system_api_key if system_api_key.present? provider_options.each_key do |provider|
config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present? 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.model_registry_file = Rails.root.join('config/llm_models.json').to_s
config.logger = Rails.logger config.logger = Rails.logger
end end
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
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 module Llm::Models
extend Llm::ProviderModelCatalog
CONFIG = YAML.load_file(Rails.root.join('config/llm.yml')).freeze CONFIG = YAML.load_file(Rails.root.join('config/llm.yml')).freeze
OPENAI_ONLY_FEATURES = %w[audio_transcription help_center_search].freeze
class << self 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 models = CONFIG.fetch('models')
def features = CONFIG.fetch('features') def features = CONFIG.fetch('features')
def feature_keys = features.keys def feature_keys = features.keys
@@ -12,31 +20,52 @@ module Llm::Models
end end
def default_model_for(feature) 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 end
def models_for(feature) def models_for(feature, provider: provider_for_feature(feature))
features.dig(feature.to_s, 'models') || [] (configured_models_for(feature, provider: provider) + provider_models_for(feature, provider: provider)).uniq
end end
def valid_model_for?(feature, model_name) def valid_model_for?(feature, model_name, provider: provider_for_feature(feature))
models_for(feature).include?(model_name.to_s) return false if model_name.blank?
models_for(feature, provider: provider).include?(model_name.to_s)
end end
def model_config(model_name) def model_config(model_name)
models[model_name.to_s] models[model_name.to_s] || ruby_llm_model_config(model_name)
end end
def provider_for(model_name) 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 end
def feature_config(feature_key) def feature_config(feature_key)
feature = features[feature_key.to_s] feature = features[feature_key.to_s]
return nil unless feature 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) model = model_config(model_name)
{ {
id: model_name, id: model_name,
@@ -46,8 +75,27 @@ module Llm::Models
credit_multiplier: model['credit_multiplier'] credit_multiplier: model['credit_multiplier']
} }
end, end,
default: feature['default'] default: default_model_for(feature_key),
provider: provider
} }
end 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
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'] 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.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 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[:alert]).to be_blank
expect(flash[:notice]).to be_blank expect(flash[:notice]).to be_blank
end 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 end
end end
@@ -10,7 +10,7 @@ RSpec.describe Captain::ConversationCompletionService do
before do before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') 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_instructions)
allow(mock_chat).to receive(:with_schema).and_return(mock_chat) allow(mock_chat).to receive(:with_schema).and_return(mock_chat)
allow(account).to receive(:feature_enabled?).and_call_original allow(account).to receive(:feature_enabled?).and_call_original
@@ -134,7 +134,9 @@ RSpec.describe Captain::ConversationCompletionService do
end end
it 'uses the system API key instead of the account hook key' do 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( allow(mock_chat).to receive(:ask).and_return(
instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5) 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 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) 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 result = service.perform
@@ -37,7 +37,7 @@ RSpec.describe Concerns::Agentable do
let(:mock_agents_agent) { instance_double(Agents::Agent) } let(:mock_agents_agent) { instance_double(Agents::Agent) }
before do 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(Agents::Agent).to receive(:new).and_return(mock_agents_agent)
allow(Captain::PromptRenderer).to receive(:render).and_return('rendered_template') allow(Captain::PromptRenderer).to receive(:render).and_return('rendered_template')
end end
@@ -167,16 +167,16 @@ RSpec.describe Concerns::Agentable do
end end
it 'returns account override model when present' do 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' }) account.update!(captain_models: { 'assistant' => 'gpt-5.2' })
expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2') expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2')
end end
it 'returns the installation model when account override is absent' do 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 end
it 'returns the assistant feature default model when account is nil' do 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 describe '#get_embedding' do
let(:account) { create(:account) } 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 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 it 'sends the installation embedding model to RubyLLM' do
configure_embedding_model('custom-embedding-model') 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]) expect(described_class.new(account_id: account.id).get_embedding('search text')).to eq([0.1, 0.2])
end 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
end end
@@ -6,28 +6,28 @@ RSpec.describe Llm::BaseAiService do
let(:account) { create(:account) } let(:account) { create(:account) }
before do 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') create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
end end
describe '#initialize' do describe '#initialize' do
it 'uses the installation model when no feature is provided' 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') expect(described_class.new.model).to eq('gpt-4.1-nano')
end end
it 'uses the account override when feature context is provided' do 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' }) account.update!(captain_models: { 'assistant' => 'gpt-5.2' })
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2') expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2')
end end
it 'uses the installation model when feature context has no account override' do 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 end
it 'uses the feature default when feature context has no account override or installation model' do 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) } let(:mock_response) { instance_double(RubyLLM::Message, content: 'Response', input_tokens: 10, output_tokens: 20) }
before do 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(:with_instructions)
allow(mock_chat).to receive(:ask).and_return(mock_response) allow(mock_chat).to receive(:ask).and_return(mock_response)
end end
@@ -138,7 +138,7 @@ RSpec.describe Captain::BaseTaskService do
end end
it 'does not make API call' do 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) service.send(:make_api_call, model: model, messages: messages)
end end
end end
@@ -158,7 +158,7 @@ RSpec.describe Captain::BaseTaskService do
end end
it 'does not make API call' do 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) service.send(:make_api_call, model: model, messages: messages)
end end
end end
@@ -171,22 +171,22 @@ RSpec.describe Captain::BaseTaskService do
it 'uses the resolved feature model for the request and instrumentation' do it 'uses the resolved feature model for the request and instrumentation' do
account.update!(captain_models: { 'editor' => 'gpt-4.1' }) 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( 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 ).and_call_original
service.send(:make_api_call, feature: 'editor', messages: messages) service.send(:make_api_call, feature: 'editor', messages: messages)
end end
it 'uses the supplied model as a feature fallback when there is no account override' do 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) service.send(:make_api_call, feature: 'document_faq_generation', model: 'gpt-5.2', messages: messages)
end end
it 'uses the help center article generation feature default' do 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) service.send(:make_api_call, feature: 'help_center_article_generation', messages: messages)
end end
@@ -194,11 +194,38 @@ RSpec.describe Captain::BaseTaskService do
it 'prefers account overrides over supplied feature fallback models' do it 'prefers account overrides over supplied feature fallback models' do
account.update!(captain_models: { 'help_center_article_generation' => 'gpt-4.1' }) 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) service.send(:make_api_call, feature: 'help_center_article_generation', model: 'gpt-5.2', messages: messages)
end 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 it 'returns formatted response with tokens' do
result = service.send(:make_api_call, model: model, messages: messages) 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) } let(:mock_response) { instance_double(RubyLLM::Message, content: 'Response', input_tokens: 10, output_tokens: 20) }
before do 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(:input_tokens).and_return(10)
allow(mock_response).to receive(:output_tokens).and_return(20) allow(mock_response).to receive(:output_tokens).and_return(20)
end end
@@ -272,7 +299,7 @@ RSpec.describe Captain::BaseTaskService do
let(:exception_tracker) { instance_double(ChatwootExceptionTracker) } let(:exception_tracker) { instance_double(ChatwootExceptionTracker) }
before do 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(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
allow(exception_tracker).to receive(:capture_exception) allow(exception_tracker).to receive(:capture_exception)
end end
@@ -295,7 +322,9 @@ RSpec.describe Captain::BaseTaskService do
it 'tracks exceptions against the system key when an account hook exists' 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' }) 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(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
expect(exception_tracker).to receive(:capture_exception) 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') create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
label1 label1
label2 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(:with_instructions)
allow(mock_chat).to receive(:ask).and_return(mock_response) allow(mock_chat).to receive(:ask).and_return(mock_response)
# Stub captain enabled check to allow specs to test base functionality # 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_chat = instance_double(RubyLLM::Chat)
mock_context = instance_double(RubyLLM::Context, chat: mock_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(:with_tool).and_return(mock_chat)
allow(mock_chat).to receive(:on_end_message).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 } } 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 before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') 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_instructions)
allow(mock_chat).to receive(:ask).and_return(mock_response) allow(mock_chat).to receive(:ask).and_return(mock_response)
# Stub captain enabled check to allow specs to test base functionality # 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 before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') 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_instructions)
allow(mock_chat).to receive(:ask).and_return(mock_response) allow(mock_chat).to receive(:ask).and_return(mock_response)
# Stub captain enabled check to allow specs to test base functionality # 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 describe '#make_api_call' do
before do before do
allow(service).to receive(:instrument_llm_call).and_yield 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 end
it 'does not track exceptions for hook key failures' do 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
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 describe '.feature_config' do
it 'returns model metadata for a feature' do it 'returns model metadata for a feature' do
config = described_class.feature_config('editor') config = described_class.feature_config('editor')
+15
View File
@@ -386,6 +386,21 @@ RSpec.describe Account do
expect(account).to be_valid expect(account).to be_valid
end 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 it 'rejects unknown feature keys' do
account.captain_models = { 'unknown_feature' => 'gpt-4.1' } account.captain_models = { 'unknown_feature' => 'gpt-4.1' }