Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53dc3f0b69 | ||
|
|
2349f1ecc5 | ||
|
|
d9519bb4ae |
@@ -50,7 +50,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(
|
||||||
@@ -73,6 +73,18 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
def restart_required_config_saved?
|
def restart_required_config_saved?
|
||||||
params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
|
params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def captain_config_options
|
||||||
|
%w[
|
||||||
|
CAPTAIN_OPEN_AI_API_KEY
|
||||||
|
CAPTAIN_OPEN_AI_MODEL
|
||||||
|
CAPTAIN_OPEN_AI_ENDPOINT
|
||||||
|
CAPTAIN_ANTHROPIC_API_KEY
|
||||||
|
CAPTAIN_ANTHROPIC_API_BASE
|
||||||
|
CAPTAIN_GEMINI_API_KEY
|
||||||
|
CAPTAIN_GEMINI_API_BASE
|
||||||
|
]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
|
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ 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_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[
|
||||||
|
|||||||
@@ -195,6 +195,24 @@
|
|||||||
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_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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ module Enterprise::SuperAdmin::AppConfigsController
|
|||||||
CAPTAIN_OPEN_AI_API_KEY
|
CAPTAIN_OPEN_AI_API_KEY
|
||||||
CAPTAIN_OPEN_AI_MODEL
|
CAPTAIN_OPEN_AI_MODEL
|
||||||
CAPTAIN_OPEN_AI_ENDPOINT
|
CAPTAIN_OPEN_AI_ENDPOINT
|
||||||
|
CAPTAIN_ANTHROPIC_API_KEY
|
||||||
|
CAPTAIN_ANTHROPIC_API_BASE
|
||||||
|
CAPTAIN_GEMINI_API_KEY
|
||||||
|
CAPTAIN_GEMINI_API_BASE
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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 || installation_model.presence || route&.dig(:model) || DEFAULT_MODEL
|
||||||
|
setup_provider(route)
|
||||||
end
|
end
|
||||||
|
|
||||||
def feature_route
|
def feature_route
|
||||||
@@ -53,6 +58,14 @@ class Llm::BaseAiService
|
|||||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
|
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
|
||||||
end
|
end
|
||||||
|
|
||||||
|
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
|
def setup_temperature
|
||||||
@temperature = DEFAULT_TEMPERATURE
|
@temperature = DEFAULT_TEMPERATURE
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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_api_key(credential[:api_key], provider: provider, api_base: api_base) 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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_api_key(credential[:api_key], provider: llm_provider, api_base: api_base) 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]
|
||||||
|
|||||||
+93
-15
@@ -2,11 +2,25 @@ require 'ruby_llm'
|
|||||||
|
|
||||||
module Llm::Config
|
module Llm::Config
|
||||||
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
|
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
|
||||||
|
DEFAULT_PROVIDER = 'openai'.freeze
|
||||||
|
|
||||||
|
PROVIDER_CONFIGS = {
|
||||||
|
'openai' => {
|
||||||
|
api_key: 'CAPTAIN_OPEN_AI_API_KEY',
|
||||||
|
api_base: 'CAPTAIN_OPEN_AI_ENDPOINT'
|
||||||
|
},
|
||||||
|
'anthropic' => {
|
||||||
|
api_key: 'CAPTAIN_ANTHROPIC_API_KEY',
|
||||||
|
api_base: 'CAPTAIN_ANTHROPIC_API_BASE'
|
||||||
|
},
|
||||||
|
'gemini' => {
|
||||||
|
api_key: 'CAPTAIN_GEMINI_API_KEY',
|
||||||
|
api_base: 'CAPTAIN_GEMINI_API_BASE'
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def initialized?
|
def initialized? = @initialized ||= false
|
||||||
@initialized ||= false
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize!
|
def initialize!
|
||||||
return if @initialized
|
return if @initialized
|
||||||
@@ -15,37 +29,101 @@ 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)
|
||||||
initialize!
|
initialize!
|
||||||
context = RubyLLM.context do |config|
|
context = RubyLLM.context do |config|
|
||||||
config.openai_api_key = api_key
|
configure_provider(config, provider: provider, api_key: api_key, api_base: api_base)
|
||||||
config.openai_api_base = api_base
|
|
||||||
end
|
end
|
||||||
|
|
||||||
yield context
|
yield context
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ruby_llm_provider_supported?(provider)
|
||||||
|
RubyLLM::Provider.providers.key?(provider.to_s.to_sym)
|
||||||
|
end
|
||||||
|
|
||||||
|
def provider_options
|
||||||
|
PROVIDER_CONFIGS.keys.each_with_object({}) do |provider, result|
|
||||||
|
next unless ruby_llm_provider_supported?(provider)
|
||||||
|
|
||||||
|
result[provider] = ruby_llm_provider_name(provider)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def api_key_for(provider)
|
||||||
|
installation_config_value(provider, :api_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def api_base_for(provider)
|
||||||
|
api_base = installation_config_value(provider, :api_base).presence
|
||||||
|
return if api_base.blank?
|
||||||
|
|
||||||
|
normalized_api_base(provider, api_base)
|
||||||
|
end
|
||||||
|
|
||||||
|
def provider_configured?(provider)
|
||||||
|
api_key_for(provider).present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def openai_provider?(provider)
|
||||||
|
provider.to_s == DEFAULT_PROVIDER
|
||||||
|
end
|
||||||
|
|
||||||
|
def supports_tools_and_schema?(provider)
|
||||||
|
openai_provider?(provider)
|
||||||
|
end
|
||||||
|
|
||||||
|
def configure_provider(config, provider:, api_key:, api_base: nil)
|
||||||
|
provider = provider.to_s
|
||||||
|
options = provider_configuration_options(provider)
|
||||||
|
api_key_option = :"#{provider}_api_key"
|
||||||
|
api_base_option = :"#{provider}_api_base"
|
||||||
|
|
||||||
|
set_config_value(config, api_key_option, api_key) if api_key.present? && options.include?(api_key_option)
|
||||||
|
set_config_value(config, api_base_option, api_base) if api_base.present? && options.include?(api_base_option)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
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_CONFIGS.each_key do |provider|
|
||||||
config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present?
|
next unless ruby_llm_provider_supported?(provider)
|
||||||
|
|
||||||
|
configure_provider(config, provider: provider, api_key: api_key_for(provider), api_base: api_base_for(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
|
def ruby_llm_provider_name(provider)
|
||||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
|
RubyLLM::Provider.providers[provider.to_s.to_sym].name
|
||||||
end
|
end
|
||||||
|
|
||||||
def openai_endpoint
|
def provider_configuration_options(provider)
|
||||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value
|
RubyLLM::Provider.providers[provider.to_s.to_sym]&.configuration_options || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_config_value(config, option, value)
|
||||||
|
setter = :"#{option}="
|
||||||
|
config.public_send(setter, value) if config.respond_to?(setter)
|
||||||
|
end
|
||||||
|
|
||||||
|
def installation_config_value(provider, key)
|
||||||
|
config_name = PROVIDER_CONFIGS.dig(provider.to_s, key)
|
||||||
|
return if config_name.blank?
|
||||||
|
|
||||||
|
InstallationConfig.find_by(name: config_name)&.value
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalized_api_base(provider, api_base)
|
||||||
|
endpoint = api_base.chomp('/').delete_suffix('/chat/completions')
|
||||||
|
return "#{endpoint}/v1" if openai_provider?(provider) && endpoint.exclude?('/v1')
|
||||||
|
|
||||||
|
endpoint
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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, provider: provider, source: :hook } if key
|
||||||
|
end
|
||||||
|
|
||||||
|
def system_llm_credential
|
||||||
|
key = Llm::Config.api_key_for(provider).presence
|
||||||
|
{ api_key: key, provider: provider, source: :system } if key
|
||||||
|
end
|
||||||
|
end
|
||||||
+16
-2
@@ -12,11 +12,14 @@ module Llm::Models
|
|||||||
end
|
end
|
||||||
|
|
||||||
def default_model_for(feature)
|
def default_model_for(feature)
|
||||||
features.dig(feature.to_s, 'default')
|
default_model = features.dig(feature.to_s, 'default')
|
||||||
|
return default_model if supported_model?(default_model)
|
||||||
|
|
||||||
|
models_for(feature).first
|
||||||
end
|
end
|
||||||
|
|
||||||
def models_for(feature)
|
def models_for(feature)
|
||||||
features.dig(feature.to_s, 'models') || []
|
(features.dig(feature.to_s, 'models') || []).select { |model_name| supported_model?(model_name) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def valid_model_for?(feature, model_name)
|
def valid_model_for?(feature, model_name)
|
||||||
@@ -31,6 +34,17 @@ module Llm::Models
|
|||||||
model_config(model_name)&.dig('provider')
|
model_config(model_name)&.dig('provider')
|
||||||
end
|
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)
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -64,6 +64,7 @@ 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
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ 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_ANTHROPIC_API_KEY: 'anthropic-key', CAPTAIN_GEMINI_API_KEY: 'gemini-key' } }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:found)
|
||||||
|
expect(GlobalConfig.get('CAPTAIN_ANTHROPIC_API_KEY')['CAPTAIN_ANTHROPIC_API_KEY']).to eq('anthropic-key')
|
||||||
|
expect(GlobalConfig.get('CAPTAIN_GEMINI_API_KEY')['CAPTAIN_GEMINI_API_KEY']).to eq('gemini-key')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
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,34 @@ 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_ANTHROPIC_API_KEY', value: 'anthropic-key')
|
||||||
|
account.update!(captain_models: { 'assistant' => 'claude-haiku-4.5' })
|
||||||
|
|
||||||
|
expect(Llm::Config).to receive(:with_api_key).with('anthropic-key', provider: 'anthropic', api_base: nil).and_yield(mock_context)
|
||||||
|
expect(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_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)
|
||||||
|
|
||||||
@@ -295,7 +318,7 @@ 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_api_key).with('test-key', provider: 'openai', api_base: nil).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)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 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'
|
||||||
|
)
|
||||||
|
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
|
||||||
@@ -40,6 +40,16 @@ 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
|
||||||
|
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')
|
||||||
|
|||||||
Reference in New Issue
Block a user