Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9519bb4ae |
@@ -195,6 +195,24 @@
|
||||
display_title: 'OpenAI API Endpoint (optional)'
|
||||
description: 'The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/'
|
||||
locked: false
|
||||
- name: CAPTAIN_ANTHROPIC_API_KEY
|
||||
display_title: 'Anthropic API Key'
|
||||
description: 'The API key used to authenticate requests to Anthropic models for Captain AI.'
|
||||
locked: false
|
||||
type: secret
|
||||
- name: CAPTAIN_ANTHROPIC_API_BASE
|
||||
display_title: 'Anthropic API Base (optional)'
|
||||
description: 'The Anthropic endpoint configured for use in Captain AI. Defaults to RubyLLM provider settings.'
|
||||
locked: false
|
||||
- name: CAPTAIN_GEMINI_API_KEY
|
||||
display_title: 'Gemini API Key'
|
||||
description: 'The API key used to authenticate requests to Gemini models for Captain AI.'
|
||||
locked: false
|
||||
type: secret
|
||||
- name: CAPTAIN_GEMINI_API_BASE
|
||||
display_title: 'Gemini API Base (optional)'
|
||||
description: 'The Gemini endpoint configured for use in Captain AI. Defaults to RubyLLM provider settings.'
|
||||
locked: false
|
||||
- name: CAPTAIN_EMBEDDING_MODEL
|
||||
display_title: 'Embedding Model (optional)'
|
||||
description: 'The embedding model configured for use in Captain AI. Default: text-embedding-3-small'
|
||||
|
||||
+93
-15
@@ -2,11 +2,25 @@ require 'ruby_llm'
|
||||
|
||||
module Llm::Config
|
||||
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
|
||||
end
|
||||
def initialized? = @initialized ||= false
|
||||
|
||||
def initialize!
|
||||
return if @initialized
|
||||
@@ -15,37 +29,101 @@ module Llm::Config
|
||||
@initialized = true
|
||||
end
|
||||
|
||||
def reset!
|
||||
@initialized = false
|
||||
end
|
||||
def reset! = @initialized = false
|
||||
|
||||
def with_api_key(api_key, api_base: nil)
|
||||
def with_api_key(api_key, provider: DEFAULT_PROVIDER, api_base: nil)
|
||||
initialize!
|
||||
context = RubyLLM.context do |config|
|
||||
config.openai_api_key = api_key
|
||||
config.openai_api_base = api_base
|
||||
configure_provider(config, provider: provider, api_key: api_key, api_base: api_base)
|
||||
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)
|
||||
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
|
||||
|
||||
def configure_ruby_llm
|
||||
RubyLLM.configure do |config|
|
||||
config.openai_api_key = system_api_key if system_api_key.present?
|
||||
config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present?
|
||||
PROVIDER_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))
|
||||
end
|
||||
config.model_registry_file = Rails.root.join('config/llm_models.json').to_s
|
||||
config.logger = Rails.logger
|
||||
end
|
||||
end
|
||||
|
||||
def system_api_key
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
|
||||
def ruby_llm_provider_name(provider)
|
||||
RubyLLM::Provider.providers[provider.to_s.to_sym].name
|
||||
end
|
||||
|
||||
def openai_endpoint
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value
|
||||
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
-2
@@ -12,11 +12,14 @@ module Llm::Models
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def models_for(feature)
|
||||
features.dig(feature.to_s, 'models') || []
|
||||
(features.dig(feature.to_s, 'models') || []).select { |model_name| supported_model?(model_name) }
|
||||
end
|
||||
|
||||
def valid_model_for?(feature, model_name)
|
||||
@@ -31,6 +34,17 @@ module Llm::Models
|
||||
model_config(model_name)&.dig('provider')
|
||||
end
|
||||
|
||||
def supported_provider?(provider)
|
||||
providers.key?(provider.to_s) && Llm::Config.ruby_llm_provider_supported?(provider)
|
||||
end
|
||||
|
||||
def supported_model?(model_name)
|
||||
config = model_config(model_name)
|
||||
return false unless config
|
||||
|
||||
supported_provider?(config['provider'])
|
||||
end
|
||||
|
||||
def feature_config(feature_key)
|
||||
feature = features[feature_key.to_s]
|
||||
return nil unless feature
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
it 'returns model metadata for a feature' do
|
||||
config = described_class.feature_config('editor')
|
||||
|
||||
Reference in New Issue
Block a user