fix(captain): support dynamic llm providers
This commit is contained in:
@@ -76,7 +76,7 @@ class Captain::BaseTaskService
|
||||
provider = llm_route[:provider]
|
||||
credential = llm_credential(provider)
|
||||
|
||||
Llm::Config.with_api_key(credential[:api_key], provider: provider, api_base: api_base) do |context|
|
||||
Llm::Config.with_provider(provider: provider, config_values: credential[:config_values]) do |context|
|
||||
chat = build_chat(context, llm_route: llm_route, messages: messages, schema: schema, tools: tools)
|
||||
|
||||
conversation_messages = messages.reject { |m| m[:role] == 'system' }
|
||||
|
||||
@@ -103,7 +103,7 @@ class Integrations::LlmBaseService
|
||||
credential = llm_credential
|
||||
return { error: I18n.t('captain.api_key_missing'), error_code: 401, request_messages: messages } if credential.blank?
|
||||
|
||||
Llm::Config.with_api_key(credential[:api_key], provider: llm_provider, api_base: api_base) do |context|
|
||||
Llm::Config.with_provider(provider: llm_provider, config_values: credential[:config_values]) do |context|
|
||||
chat = Llm::ProviderChat.new(context.chat(model: model, provider: llm_provider, assume_model_exists: true), provider: llm_provider)
|
||||
setup_chat_with_messages(chat, messages)
|
||||
end
|
||||
|
||||
+16
-89
@@ -1,24 +1,12 @@
|
||||
require 'ruby_llm'
|
||||
require_relative 'provider_config'
|
||||
|
||||
module Llm::Config
|
||||
extend Llm::ProviderConfig
|
||||
|
||||
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
|
||||
DEFAULT_PROVIDER = 'openai'.freeze
|
||||
|
||||
PROVIDER_CONFIGS = {
|
||||
'openai' => {
|
||||
api_key: 'CAPTAIN_OPEN_AI_API_KEY',
|
||||
api_base: 'CAPTAIN_OPEN_AI_ENDPOINT'
|
||||
},
|
||||
'anthropic' => {
|
||||
api_key: 'CAPTAIN_ANTHROPIC_API_KEY',
|
||||
api_base: 'CAPTAIN_ANTHROPIC_API_BASE'
|
||||
},
|
||||
'gemini' => {
|
||||
api_key: 'CAPTAIN_GEMINI_API_KEY',
|
||||
api_base: 'CAPTAIN_GEMINI_API_BASE'
|
||||
}
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
def initialized? = @initialized ||= false
|
||||
|
||||
@@ -31,99 +19,38 @@ module Llm::Config
|
||||
|
||||
def reset! = @initialized = false
|
||||
|
||||
def with_api_key(api_key, provider: DEFAULT_PROVIDER, api_base: nil)
|
||||
def with_api_key(api_key, provider: DEFAULT_PROVIDER, api_base: nil, config_values: nil)
|
||||
initialize!
|
||||
context = RubyLLM.context do |config|
|
||||
configure_provider(config, provider: provider, api_key: api_key, api_base: api_base)
|
||||
values = config_values || provider_config_values(provider).merge(
|
||||
:"#{provider}_api_key" => api_key,
|
||||
:"#{provider}_api_base" => api_base
|
||||
).compact
|
||||
configure_provider(config, provider: provider, config_values: values)
|
||||
end
|
||||
|
||||
yield context
|
||||
end
|
||||
|
||||
def ruby_llm_provider_supported?(provider)
|
||||
RubyLLM::Provider.providers.key?(provider.to_s.to_sym)
|
||||
end
|
||||
|
||||
def provider_options
|
||||
PROVIDER_CONFIGS.keys.each_with_object({}) do |provider, result|
|
||||
next unless ruby_llm_provider_supported?(provider)
|
||||
|
||||
result[provider] = ruby_llm_provider_name(provider)
|
||||
def with_provider(provider:, config_values: provider_config_values(provider))
|
||||
initialize!
|
||||
context = RubyLLM.context do |config|
|
||||
configure_provider(config, provider: provider, config_values: config_values)
|
||||
end
|
||||
end
|
||||
|
||||
def api_key_for(provider)
|
||||
installation_config_value(provider, :api_key)
|
||||
end
|
||||
|
||||
def api_base_for(provider)
|
||||
api_base = installation_config_value(provider, :api_base).presence
|
||||
return if api_base.blank?
|
||||
|
||||
normalized_api_base(provider, api_base)
|
||||
end
|
||||
|
||||
def provider_configured?(provider)
|
||||
api_key_for(provider).present?
|
||||
end
|
||||
|
||||
def openai_provider?(provider)
|
||||
provider.to_s == DEFAULT_PROVIDER
|
||||
end
|
||||
|
||||
def supports_tools_and_schema?(provider)
|
||||
openai_provider?(provider)
|
||||
end
|
||||
|
||||
def configure_provider(config, provider:, api_key:, api_base: nil)
|
||||
provider = provider.to_s
|
||||
options = provider_configuration_options(provider)
|
||||
api_key_option = :"#{provider}_api_key"
|
||||
api_base_option = :"#{provider}_api_base"
|
||||
|
||||
set_config_value(config, api_key_option, api_key) if api_key.present? && options.include?(api_key_option)
|
||||
set_config_value(config, api_base_option, api_base) if api_base.present? && options.include?(api_base_option)
|
||||
yield context
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def configure_ruby_llm
|
||||
RubyLLM.configure do |config|
|
||||
PROVIDER_CONFIGS.each_key do |provider|
|
||||
next unless ruby_llm_provider_supported?(provider)
|
||||
|
||||
configure_provider(config, provider: provider, api_key: api_key_for(provider), api_base: api_base_for(provider))
|
||||
provider_options.each_key do |provider|
|
||||
configure_provider(config, provider: provider, config_values: provider_config_values(provider))
|
||||
end
|
||||
config.model_registry_file = Rails.root.join('config/llm_models.json').to_s
|
||||
config.logger = Rails.logger
|
||||
end
|
||||
end
|
||||
|
||||
def ruby_llm_provider_name(provider)
|
||||
RubyLLM::Provider.providers[provider.to_s.to_sym].name
|
||||
end
|
||||
|
||||
def provider_configuration_options(provider)
|
||||
RubyLLM::Provider.providers[provider.to_s.to_sym]&.configuration_options || []
|
||||
end
|
||||
|
||||
def set_config_value(config, option, value)
|
||||
setter = :"#{option}="
|
||||
config.public_send(setter, value) if config.respond_to?(setter)
|
||||
end
|
||||
|
||||
def installation_config_value(provider, key)
|
||||
config_name = PROVIDER_CONFIGS.dig(provider.to_s, key)
|
||||
return if config_name.blank?
|
||||
|
||||
InstallationConfig.find_by(name: config_name)&.value
|
||||
end
|
||||
|
||||
def normalized_api_base(provider, api_base)
|
||||
endpoint = api_base.chomp('/').delete_suffix('/chat/completions')
|
||||
return "#{endpoint}/v1" if openai_provider?(provider) && endpoint.exclude?('/v1')
|
||||
|
||||
endpoint
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,11 +16,13 @@ class Llm::CredentialResolver
|
||||
return unless Llm::Config.openai_provider?(provider)
|
||||
|
||||
key = openai_hook&.settings&.dig('api_key').presence
|
||||
{ api_key: key, provider: provider, source: :hook } if key
|
||||
{ api_key: key, config_values: { openai_api_key: key }, provider: provider, source: :hook } if key
|
||||
end
|
||||
|
||||
def system_llm_credential
|
||||
key = Llm::Config.api_key_for(provider).presence
|
||||
{ api_key: key, provider: provider, source: :system } if key
|
||||
config_values = Llm::Config.provider_config_values(provider)
|
||||
return unless Llm::Config.provider_configured?(provider)
|
||||
|
||||
{ api_key: config_values[:"#{provider}_api_key"], config_values: config_values, provider: provider, source: :system }
|
||||
end
|
||||
end
|
||||
|
||||
+43
-4
@@ -1,8 +1,12 @@
|
||||
module Llm::Models
|
||||
CONFIG = YAML.load_file(Rails.root.join('config/llm.yml')).freeze
|
||||
OPENAI_ONLY_FEATURES = %w[audio_transcription help_center_search].freeze
|
||||
|
||||
class << self
|
||||
def providers = CONFIG.fetch('providers')
|
||||
def providers
|
||||
Llm::Config.provider_options.transform_values { |display_name| { 'display_name' => display_name } }
|
||||
end
|
||||
|
||||
def models = CONFIG.fetch('models')
|
||||
def features = CONFIG.fetch('features')
|
||||
def feature_keys = features.keys
|
||||
@@ -19,7 +23,7 @@ module Llm::Models
|
||||
end
|
||||
|
||||
def models_for(feature)
|
||||
(features.dig(feature.to_s, 'models') || []).select { |model_name| supported_model?(model_name) }
|
||||
(configured_models_for(feature) + provider_models_for(feature)).uniq
|
||||
end
|
||||
|
||||
def valid_model_for?(feature, model_name)
|
||||
@@ -27,11 +31,11 @@ module Llm::Models
|
||||
end
|
||||
|
||||
def model_config(model_name)
|
||||
models[model_name.to_s]
|
||||
models[model_name.to_s] || ruby_llm_model_config(model_name)
|
||||
end
|
||||
|
||||
def provider_for(model_name)
|
||||
model_config(model_name)&.dig('provider')
|
||||
models.dig(model_name.to_s, 'provider') || ruby_llm_model(model_name)&.provider
|
||||
end
|
||||
|
||||
def supported_provider?(provider)
|
||||
@@ -63,5 +67,40 @@ module Llm::Models
|
||||
default: feature['default']
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def configured_models_for(feature)
|
||||
(features.dig(feature.to_s, 'models') || []).select { |model_name| supported_model?(model_name) }
|
||||
end
|
||||
|
||||
def provider_models_for(feature)
|
||||
return [] if openai_only_feature?(feature)
|
||||
|
||||
provider = Llm::Config.current_provider
|
||||
return [] if provider == Llm::Config::DEFAULT_PROVIDER
|
||||
|
||||
RubyLLM.models.by_provider(provider).chat_models.map(&:id)
|
||||
end
|
||||
|
||||
def openai_only_feature?(feature)
|
||||
OPENAI_ONLY_FEATURES.include?(feature.to_s)
|
||||
end
|
||||
|
||||
def ruby_llm_model_config(model_name)
|
||||
model = ruby_llm_model(model_name)
|
||||
return unless model
|
||||
|
||||
{
|
||||
'provider' => model.provider,
|
||||
'display_name' => model.name
|
||||
}
|
||||
end
|
||||
|
||||
def ruby_llm_model(model_name)
|
||||
RubyLLM.models.find(model_name.to_s)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
require 'ruby_llm'
|
||||
|
||||
module Llm::ProviderConfig
|
||||
PROVIDER_CONFIG_PREFIX = 'CAPTAIN_LLM'.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
|
||||
(['CAPTAIN_LLM_PROVIDER'] + provider_config_options.values).uniq
|
||||
end
|
||||
|
||||
def provider_config_options
|
||||
provider_options.keys.each_with_object({}) do |provider, result|
|
||||
provider_configuration_options(provider).each do |option|
|
||||
result[option] = installation_config_name(option)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def current_provider
|
||||
provider = InstallationConfig.find_by(name: 'CAPTAIN_LLM_PROVIDER')&.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
|
||||
Reference in New Issue
Block a user